From ccec6e72bfbcb8ff498a3467840e591662885958 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 9 Sep 2026 14:22:23 +0200 Subject: [PATCH 001/316] [sendspin] Fix codec enum codegen when codecs is not set (#19055) --- esphome/components/sendspin/__init__.py | 2 +- tests/components/sendspin/common-media_source.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 8ef11a7f90..c1970ab132 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -307,7 +307,7 @@ async def to_code(config: ConfigType) -> None: player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - codecs = player_cfg[CONF_CODECS] + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 0c136fbd43..1977b79c04 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,4 +9,3 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal - codecs: [pcm, opus, flac] From ad4ee1d34e957cb867a291c24fcb20824031471e Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 10 Sep 2026 06:06:44 +0200 Subject: [PATCH 002/316] [network] Improve `network::is_connected()` to better handle multiple interfaces (#18999) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/network/util.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22f..57c5a66833 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a From 6bd6603d523160dfa2a7f0ec0b997cc606c498de Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:30 +0000 Subject: [PATCH 003/316] Bump bundled esphome-device-builder to 1.14.6 (#19072) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ac84ee4689..cfa47fbdad 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.14.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 RUN \ platformio settings set enable_telemetry No \ From 3b499ecb3e538c0f5bb20a4f166053004147840c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 03:20:30 -0500 Subject: [PATCH 004/316] [core] Support set_internal() during setup, log error after setup (#19069) --- esphome/core/entity_base.cpp | 9 ++++ esphome/core/entity_base.h | 27 ++++++++---- .../fixtures/set_internal_at_boot.yaml | 34 +++++++++++++++ .../integration/test_set_internal_at_boot.py | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/set_internal_at_boot.yaml create mode 100644 tests/integration/test_set_internal_at_boot.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706..dc27c1e56a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52..8796e9f067 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 0000000000..b3007e9dbd --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 0000000000..68b0bd1080 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} From 1975b17eac18d1d03778a753835d48c71a58dda9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:41 -0500 Subject: [PATCH 005/316] Bump bundled esphome-device-builder to 1.14.7 (#19096) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cfa47fbdad..6f500dbe6f 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.14.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 RUN \ platformio settings set enable_telemetry No \ From 6cc2b9bf1740a1cc004050b5c6e6be48e55ea65a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:12:08 +1000 Subject: [PATCH 006/316] [lvgl] Fix crash when using lvgl.list.add (#19177) --- esphome/components/lvgl/widgets/lv_list.py | 4 ++++ tests/components/lvgl/lvgl-package.yaml | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef9..7711e8bfe4 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_clear(w: Widget): await _wait_list_triggers_completed() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db35..bd2e77ee8c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,18 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + lvgl: id: lvgl_id rotation: 90 @@ -1291,7 +1303,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1347,10 +1359,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id From 3eda3060b8f938e231cc1b2a63e7b8e07f6f381f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:06:35 -0500 Subject: [PATCH 007/316] Bump bundled esphome-device-builder to 1.14.8 (#19250) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f500dbe6f..bdbbe798ce 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.14.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 RUN \ platformio settings set enable_telemetry No \ From 9814966fe7a8f48b29dac6d3f2a50bb681abe534 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:14:26 -0500 Subject: [PATCH 008/316] [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d17ebf235e..6067fde164 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.26") + cg.add_library("esphome/noise-c", "0.1.30") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.8") + cg.add_library("esphome/libsodium", "1.10021.11") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 738773d1b5..0e334ac5b4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.26 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 00f22ca138..0dce00785b 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" + assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.26"] + assert libs == ["esphome/noise-c @ 1.0"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.26", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", + "esphome/noise-c @ 1.0", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.26"] + assert cls.calls == ["esphome/noise-c @ 1.0"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.26"] is None + assert compats["esphome/noise-c @ 1.0"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index b03bff19a2..774493ecf4 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 0803d7b37ce8c0e52960ca43180cb2ad30d4c7a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:16:37 -0500 Subject: [PATCH 009/316] [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) --- esphome/core/helpers.h | 52 ++++++++++++++++++++------ script/cpp_unit_test.py | 3 +- tests/components/core/test_helpers.cpp | 19 ++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124..987c54a5b0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -539,7 +541,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +572,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +641,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +758,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab39414..8cb18d0875 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = { def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a..d6b31508d1 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,23 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + } // namespace esphome::core::testing From 9064bfcc85fc99cd2403dc109c308c9782295784 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:44:03 +0000 Subject: [PATCH 010/316] Bump bundled esphome-device-builder to 1.14.9 (#19263) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbbe798ce..e00570c8ff 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.14.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ From b17cd89469498a698cf68f441dc5dcc09c3fce15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:13 -0500 Subject: [PATCH 011/316] [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 16 ++++-- .../wifi/wifi_component_esp8266.cpp | 6 +- .../wifi/wifi_component_esp_idf.cpp | 57 +++++++++++++++---- .../wifi/wifi_component_libretiny.cpp | 6 +- esphome/core/defines.h | 2 + 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b8c6d774ac..d4b39c029b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -623,6 +623,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 694e616476..f290832a18 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1498,8 +1498,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -2415,7 +2415,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 63df9fbfa5..16b62a5bb0 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -948,6 +948,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355..60ec3f9a4d 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d21330..24bf64a99c 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342..940f2a0783 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eaece6d5ff..b78516c6ef 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -260,6 +260,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) From 1a555d58489a4c4e8e1bb30e13a5e8e30285e59f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:23 -0500 Subject: [PATCH 012/316] [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) --- .../bluetooth_connection_bluedroid.cpp | 37 +++++++++++++------ .../bluetooth_connection_bluedroid.h | 1 + esphome/components/esp32_ble/ble.cpp | 35 +++++++++++------- esphome/components/esp32_ble/ble.h | 13 ++++++- .../esp32_ble_client/ble_client_base.cpp | 35 +++++++++++++++++- .../esp32_ble_client/ble_client_base.h | 12 +++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 28 ++++++++++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 ++ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d..986a67c7a8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b..f285260e76 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf8..81fa328c16 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,18 +83,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a4..fd4fb15ff6 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda..88454f7bdb 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd5100..fbd405156a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a32..b4b793b4d0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +218,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626..1a424a4a8e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } From cd5d4ff25422e7a0142e73095f50c5a29f1a03fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:56:50 -0500 Subject: [PATCH 013/316] [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) --- esphome/core/helpers.h | 40 ++++++++++++++++++++ tests/components/core/test_helpers.cpp | 52 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 987c54a5b0..b1f24b25a3 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -2123,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2193,6 +2200,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2255,6 +2282,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index d6b31508d1..72af605d61 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -367,4 +367,56 @@ TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { EXPECT_EQ(v.size(), 1u); } +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing From 64fd87f43670a0112a8b1b21eed346f8d315fd72 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 13 Sep 2026 18:01:58 -0400 Subject: [PATCH 014/316] [i2s_audio][router] Loop thread controls all state changes (#19089) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 ++++++++----- .../router/speaker/router_speaker.cpp | 21 ++++++++++++++++--- .../router/speaker/router_speaker.h | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1382a87046..9feaf39fff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab..dd2428e4df 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906ce..31f3f90630 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); From be2c3dda94ea17d596e80b3332f920d783921503 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:02:42 -0500 Subject: [PATCH 015/316] [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b4b793b4d0..e25b6f59fa 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; From b0b75f705a6cda23e12ed33fac7c6f1e307e3eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:14 -0500 Subject: [PATCH 016/316] [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) --- esphome/components/nextion/nextion.cpp | 152 +++++++++--------- esphome/components/nextion/nextion.h | 2 + .../nextion/nextion_component_base.h | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d5..625c915e73 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3..6c9c8760f8 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d01920..5e84291b16 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -105,6 +104,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion From 977542061d285694ab94967757d70f0a698a2269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:28 -0500 Subject: [PATCH 017/316] [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++++- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/components/esphome/ota/ota_esphome_noise.cpp | 6 ++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f853ed6a2d..3010df1056 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fc..68dd0ffb9e 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION #ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d..65476572a1 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default-init: the frame buffer is written before it is read - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags From ebe72c3cefaaad5e664a0f9f400d88da5b11262d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:27:03 -0500 Subject: [PATCH 018/316] [core] Resolve file paths against the YAML file that declares them (#19259) --- esphome/config_validation.py | 66 +++++++----- tests/unit_tests/test_config_validation.py | 120 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3..2346c28cce 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -15,6 +15,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1967,38 +1968,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b..52070e7aba 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3145,6 +3153,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") From e28b4eb2a0584792102702af9f1a47fc2b5d751f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:35:38 -0500 Subject: [PATCH 019/316] [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) --- .../components/ethernet/w5500_custom_spi.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738..9c6b59582a 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } From 81a54ea9dbbd7c5482057f9993ce8bda1a4f7047 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:37:04 -0500 Subject: [PATCH 020/316] [ota] Allocate the signature block through RAMAllocator (#19251) --- esphome/components/ota/ota_signature_esp_idf.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241..2192a79441 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; From 501009073d1f6da6245c4100fc2de1a44a202922 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:13 +1200 Subject: [PATCH 021/316] [core] Clear loaded_platforms on CORE.reset() (#19268) --- esphome/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22..5fcad90a81 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} From 93fa95c8335585fc5d6492ca01039b252ae3820e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 20:03:00 -0500 Subject: [PATCH 022/316] [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) --- esphome/components/api/__init__.py | 5 +- esphome/components/api/api_buffer.cpp | 35 +- esphome/components/api/api_buffer.h | 24 +- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 3 + .../components/api/api_frame_helper_noise.cpp | 20 +- .../components/api/api_overflow_buffer.cpp | 121 ++--- esphome/components/api/api_overflow_buffer.h | 93 ++-- tests/components/api/__init__.py | 17 + tests/components/api/test_api_buffer.cpp | 65 +++ tests/components/api/test_overflow_buffer.cpp | 510 ++++++++++++++++++ 12 files changed, 755 insertions(+), 145 deletions(-) create mode 100644 tests/components/api/__init__.py create mode 100644 tests/components/api/test_api_buffer.cpp create mode 100644 tests/components/api/test_overflow_buffer.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bf..272b078690 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971..62a544b1a4 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe58..7caa68aa4d 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702..cc0543a690 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a18..41d1230aaa 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c..a68a0ad0d8 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee..400cd1d9b8 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba..0b5a874d4b 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281..e2e4b9c3c3 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 0000000000..2aa558726c --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.core import CORE +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + CORE.register_controller() # api_server registers with the controller registry + + manifest.to_code = to_code_testing diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 0000000000..c54780050e --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 0000000000..4b27e54496 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST From 7fe0689fb8e1b0b94ce2f0e3284533ecb61d9981 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:12:27 +1200 Subject: [PATCH 023/316] Bump version to 2026.9.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 97ce92240c..331d2f7984 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b3 +PROJECT_NUMBER = 2026.9.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b013098f33..5696125355 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b3" +__version__ = "2026.9.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 02648d3547401d6478149c9c9f322a5b99a467fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 024/316] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d..45bf77b4e8 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From 82b608706cd49a231017190de0bbb8120ccc2fbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 025/316] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d..45bf77b4e8 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From abadfbfd20eb16d9272ef225f160e55adad2824b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 026/316] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f..9ef7efc96a 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f14..3e7d345805 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc..766cdc4afb 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c..3b632a1847 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b7..5c8d71696f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1e57c03b7b..95f627596d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -288,7 +288,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -487,7 +489,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 0000000000..f7e0374319 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 282be54d1eb4c4a1ab00288229c247a06ce7975f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 027/316] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f6..fc0893323b 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9b..3c7942b9a3 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 0000000000..b3eae34436 --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 0000000000..b33508602a --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 1e22861d11ddcd27096239b2d7d4ea130b83883c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 028/316] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f7..2459163786 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f..72d3bf75b1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 0000000000..dae1c48883 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 0000000000..2d37d7ae19 --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 0000000000..3c34da43ac --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 0000000000..2b13ed966b --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From 4067f572cc953f5f98d1f2770222a4245ab21dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 029/316] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5..10d5e5eb59 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f423572..57c8c553f6 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 0000000000..84c5eafc5a --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 0000000000..682ae9de51 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 0000000000..172715aef0 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a..8fa62c0636 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 8109aa96f628711482501d2bd5de1406eb764bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 030/316] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a..ab9624c364 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368..eafa161f51 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 0000000000..ecb33b0ea8 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 0000000000..a4fc24a7cb --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From cba4f5bc05dfad191c27131a7b731caf7a1df53a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 031/316] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 95f627596d..81b90766b9 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -169,6 +169,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -496,7 +499,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -606,7 +609,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -655,7 +659,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + 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") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -677,10 +683,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 77a4773a27..a0983545fb 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -919,11 +919,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + 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 #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 0000000000..94e5de47a0 --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 0000000000..068479a540 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 0000000000..1b5e7d7dba --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 0000000000..b326f3eaee --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From c378ea13001066844f84b13fd8a86c016525536b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 032/316] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b03084..138db75ad1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7e..ae55f4145a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7b..4ce30afb94 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 0000000000..1f11ea4580 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 0000000000..dc25fe99ce --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 0000000000..ce45a6ae3f --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From e5eb577b49ed824d3fd1a82b633f52e94c51f7cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 033/316] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6..79c2e50984 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e04..be6e63b154 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 0000000000..9ffc8af736 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 0000000000..771e513345 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 1e627a31f1027478206e15c3fe27d2334026e034 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:36:30 -0500 Subject: [PATCH 034/316] [ci] Refresh integration test durations (#19275) --- .../integration_test_durations.json | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index b4a7f4e1ae..16748b4537 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,154 +1,155 @@ { - "tests/integration/test_action_concurrent_reentry.py": 30.48, - "tests/integration/test_addressable_light_transition.py": 42.1, - "tests/integration/test_alarm_control_panel_state_transitions.py": 35.76, - "tests/integration/test_api_action_metadata.py": 22.35, - "tests/integration/test_api_action_responses.py": 30.31, - "tests/integration/test_api_action_timeout.py": 34.73, - "tests/integration/test_api_conditional_memory.py": 18.35, - "tests/integration/test_api_custom_services.py": 15.99, - "tests/integration/test_api_get_time_response_timezone.py": 24.21, - "tests/integration/test_api_homeassistant.py": 33.77, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55, - "tests/integration/test_api_list_entities_backpressure.py": 23.04, - "tests/integration/test_api_message_size_batching.py": 27.31, - "tests/integration/test_api_reboot_timeout.py": 29.32, - "tests/integration/test_api_string_lambda.py": 14.9, - "tests/integration/test_api_vv_logging.py": 26.25, - "tests/integration/test_api_zero_psk_provisioning.py": 38.19, - "tests/integration/test_areas_and_devices.py": 27.52, - "tests/integration/test_automation_wait_actions.py": 24.25, - "tests/integration/test_automations.py": 36.02, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47, - "tests/integration/test_binary_sensor_invalidate_state.py": 14.79, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52, - "tests/integration/test_build_info.py": 21.42, - "tests/integration/test_camera_mock.py": 17.02, - "tests/integration/test_climate_control_action.py": 26.56, - "tests/integration/test_climate_custom_modes.py": 18.82, - "tests/integration/test_continuation_actions.py": 20.39, - "tests/integration/test_cover_control_action.py": 19.91, - "tests/integration/test_crc8_helper.py": 16.73, - "tests/integration/test_device_id_in_state.py": 58.41, - "tests/integration/test_duplicate_entities.py": 30.76, - "tests/integration/test_entity_icon.py": 25.34, - "tests/integration/test_fan_turn_on_action.py": 23.64, - "tests/integration/test_fnv1_hash_object_id.py": 25.44, - "tests/integration/test_fnv1a_hash.py": 20.85, - "tests/integration/test_gpio_expander_cache.py": 14.42, - "tests/integration/test_host_logger_thread_safety.py": 21.31, - "tests/integration/test_host_mode_basic.py": 2.65, - "tests/integration/test_host_mode_batch_delay.py": 22.21, - "tests/integration/test_host_mode_climate_basic_state.py": 27.12, - "tests/integration/test_host_mode_climate_control.py": 21.57, - "tests/integration/test_host_mode_empty_string_options.py": 27.17, - "tests/integration/test_host_mode_entity_fields.py": 30.1, - "tests/integration/test_host_mode_fan_preset.py": 17.55, - "tests/integration/test_host_mode_many_entities.py": 38.98, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82, - "tests/integration/test_host_mode_noise_encryption.py": 39.84, - "tests/integration/test_host_mode_reconnect.py": 13.1, - "tests/integration/test_host_mode_sensor.py": 22.17, - "tests/integration/test_host_ota.py": 92.05, - "tests/integration/test_host_preferences.py": 20.29, - "tests/integration/test_host_preferences_suspend_resume.py": 15.02, - "tests/integration/test_improv_serial_uart.py": 30.15, - "tests/integration/test_large_message_batching.py": 25.84, - "tests/integration/test_legacy_area.py": 21.24, - "tests/integration/test_legacy_climate_compat.py": 17.34, - "tests/integration/test_legacy_fan_compat.py": 22.6, - "tests/integration/test_light_automations.py": 29.13, - "tests/integration/test_light_binary_effect_off_phase.py": 33.99, - "tests/integration/test_light_calls.py": 26.81, - "tests/integration/test_light_constant_brightness.py": 25.0, - "tests/integration/test_light_control_action.py": 25.57, - "tests/integration/test_light_dim_relative_action.py": 21.4, - "tests/integration/test_light_effect_zero_brightness.py": 19.65, - "tests/integration/test_light_initial_state.py": 17.58, - "tests/integration/test_light_toggle_action.py": 28.28, - "tests/integration/test_lock_automations.py": 23.3, - "tests/integration/test_logger_buffered_recursion_guard.py": 22.96, - "tests/integration/test_loop_disable_enable.py": 16.18, - "tests/integration/test_loop_interval_decoupling.py": 25.19, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59, - "tests/integration/test_lvgl_headless_render.py": 87.78, - "tests/integration/test_micros_to_millis.py": 18.73, - "tests/integration/test_multi_click_trigger.py": 24.2, - "tests/integration/test_multi_device_preferences.py": 20.52, - "tests/integration/test_noise_encryption_key_protection.py": 19.1, - "tests/integration/test_object_id_api_verification.py": 26.24, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88, - "tests/integration/test_object_id_no_friendly_name.py": 61.27, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03, - "tests/integration/test_online_image_bmp.py": 34.21, - "tests/integration/test_oversized_payloads.py": 62.75, - "tests/integration/test_preference_key_stability.py": 26.8, - "tests/integration/test_runtime_stats.py": 28.26, - "tests/integration/test_safe_mode_loop_runs.py": 18.14, - "tests/integration/test_scheduler_blocking_warning.py": 28.7, - "tests/integration/test_scheduler_bulk_cleanup.py": 20.73, - "tests/integration/test_scheduler_defer_cancel.py": 22.99, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.97, - "tests/integration/test_scheduler_defer_fifo_simple.py": 24.15, - "tests/integration/test_scheduler_defer_stress.py": 23.11, - "tests/integration/test_scheduler_heap_stress.py": 20.2, - "tests/integration/test_scheduler_internal_id_no_collision.py": 23.75, - "tests/integration/test_scheduler_interval_reschedule.py": 15.32, - "tests/integration/test_scheduler_interval_zero_coerced.py": 20.1, - "tests/integration/test_scheduler_null_name.py": 17.43, - "tests/integration/test_scheduler_numeric_id_test.py": 25.51, - "tests/integration/test_scheduler_pool.py": 24.22, - "tests/integration/test_scheduler_rapid_cancellation.py": 24.01, - "tests/integration/test_scheduler_recursive_timeout.py": 22.94, - "tests/integration/test_scheduler_removed_item_race.py": 23.07, - "tests/integration/test_scheduler_self_keyed.py": 18.43, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99, - "tests/integration/test_scheduler_string_test.py": 17.27, - "tests/integration/test_script_array_params.py": 4.59, - "tests/integration/test_script_delay_params.py": 22.46, - "tests/integration/test_script_queued.py": 25.24, - "tests/integration/test_script_queued_idle_loop.py": 5.04, - "tests/integration/test_script_wait_on_boot.py": 21.77, - "tests/integration/test_sdl_headless_screenshot.py": 19.23, - "tests/integration/test_select_stringref_trigger.py": 19.31, - "tests/integration/test_sensor_filters_delta.py": 25.92, - "tests/integration/test_sensor_filters_ring_buffer.py": 22.39, - "tests/integration/test_sensor_filters_sliding_window.py": 57.93, - "tests/integration/test_sensor_filters_value_list.py": 20.32, - "tests/integration/test_sensor_timeout_filter.py": 25.35, - "tests/integration/test_snapshot_display.py": 19.7, - "tests/integration/test_socket_wake_gate_tcp.py": 14.5, - "tests/integration/test_status_flags.py": 33.83, - "tests/integration/test_strftime_to.py": 17.64, - "tests/integration/test_syslog.py": 24.49, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81, - "tests/integration/test_template_climate_basic.py": 15.28, - "tests/integration/test_template_climate_custom_modes.py": 25.07, - "tests/integration/test_template_climate_nonoptimistic.py": 24.25, - "tests/integration/test_template_climate_on_control_ordering.py": 24.09, - "tests/integration/test_template_climate_publish_all_fields.py": 17.78, - "tests/integration/test_template_climate_sensor_push.py": 17.42, - "tests/integration/test_template_climate_set_actions.py": 23.63, - "tests/integration/test_template_climate_two_point_temperature.py": 25.19, - "tests/integration/test_template_text_save.py": 17.88, - "tests/integration/test_text_command.py": 22.71, - "tests/integration/test_text_sensor_raw_state.py": 25.17, - "tests/integration/test_uart_mock_ld2410.py": 58.15, - "tests/integration/test_uart_mock_ld2412.py": 61.14, - "tests/integration/test_uart_mock_ld2420.py": 33.87, - "tests/integration/test_uart_mock_ld2450.py": 26.06, - "tests/integration/test_uart_mock_modbus.py": 391.79, - "tests/integration/test_udp.py": 7.38, - "tests/integration/test_use_address_runtime.py": 24.09, - "tests/integration/test_valve_control_action.py": 23.22, - "tests/integration/test_varint_five_byte_device_id.py": 17.93, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.26, - "tests/integration/test_wait_until_on_boot.py": 17.46, - "tests/integration/test_wait_until_ordering.py": 11.89, - "tests/integration/test_wait_until_reentrant_restart.py": 22.88, - "tests/integration/test_wake_loop_forces_phase_b.py": 16.6, - "tests/integration/test_water_heater_template.py": 19.66 + "tests/integration/test_action_concurrent_reentry.py": 34.72, + "tests/integration/test_addressable_light_transition.py": 33.71, + "tests/integration/test_alarm_control_panel_state_transitions.py": 38.96, + "tests/integration/test_api_action_metadata.py": 33.36, + "tests/integration/test_api_action_responses.py": 26.22, + "tests/integration/test_api_action_timeout.py": 25.41, + "tests/integration/test_api_conditional_memory.py": 20.74, + "tests/integration/test_api_custom_services.py": 23.21, + "tests/integration/test_api_get_time_response_timezone.py": 25.04, + "tests/integration/test_api_homeassistant.py": 24.18, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86, + "tests/integration/test_api_list_entities_backpressure.py": 18.8, + "tests/integration/test_api_message_size_batching.py": 28.8, + "tests/integration/test_api_reboot_timeout.py": 9.47, + "tests/integration/test_api_string_lambda.py": 16.88, + "tests/integration/test_api_vv_logging.py": 17.99, + "tests/integration/test_api_zero_psk_provisioning.py": 47.07, + "tests/integration/test_areas_and_devices.py": 20.77, + "tests/integration/test_automation_wait_actions.py": 20.07, + "tests/integration/test_automations.py": 27.41, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54, + "tests/integration/test_binary_sensor_invalidate_state.py": 16.09, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7, + "tests/integration/test_build_info.py": 18.07, + "tests/integration/test_camera_mock.py": 20.44, + "tests/integration/test_climate_control_action.py": 27.97, + "tests/integration/test_climate_custom_modes.py": 26.77, + "tests/integration/test_continuation_actions.py": 12.09, + "tests/integration/test_cover_control_action.py": 19.77, + "tests/integration/test_crc8_helper.py": 12.64, + "tests/integration/test_device_id_in_state.py": 63.19, + "tests/integration/test_duplicate_entities.py": 29.26, + "tests/integration/test_entity_icon.py": 34.95, + "tests/integration/test_fan_turn_on_action.py": 25.98, + "tests/integration/test_fnv1_hash_object_id.py": 4.85, + "tests/integration/test_fnv1a_hash.py": 5.14, + "tests/integration/test_gpio_expander_cache.py": 21.0, + "tests/integration/test_host_logger_thread_safety.py": 17.51, + "tests/integration/test_host_mode_basic.py": 21.2, + "tests/integration/test_host_mode_batch_delay.py": 26.68, + "tests/integration/test_host_mode_climate_basic_state.py": 18.04, + "tests/integration/test_host_mode_climate_control.py": 29.64, + "tests/integration/test_host_mode_empty_string_options.py": 28.8, + "tests/integration/test_host_mode_entity_fields.py": 28.68, + "tests/integration/test_host_mode_fan_preset.py": 16.98, + "tests/integration/test_host_mode_many_entities.py": 39.8, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12, + "tests/integration/test_host_mode_noise_encryption.py": 52.53, + "tests/integration/test_host_mode_reconnect.py": 14.48, + "tests/integration/test_host_mode_sensor.py": 17.38, + "tests/integration/test_host_ota.py": 94.96, + "tests/integration/test_host_preferences.py": 27.11, + "tests/integration/test_host_preferences_suspend_resume.py": 21.48, + "tests/integration/test_improv_serial_uart.py": 19.43, + "tests/integration/test_large_message_batching.py": 25.67, + "tests/integration/test_legacy_area.py": 15.59, + "tests/integration/test_legacy_climate_compat.py": 18.59, + "tests/integration/test_legacy_fan_compat.py": 18.34, + "tests/integration/test_light_automations.py": 16.74, + "tests/integration/test_light_binary_effect_off_phase.py": 57.7, + "tests/integration/test_light_calls.py": 25.88, + "tests/integration/test_light_constant_brightness.py": 22.32, + "tests/integration/test_light_control_action.py": 18.15, + "tests/integration/test_light_dim_relative_action.py": 30.35, + "tests/integration/test_light_effect_zero_brightness.py": 18.38, + "tests/integration/test_light_initial_state.py": 23.93, + "tests/integration/test_light_toggle_action.py": 26.16, + "tests/integration/test_lock_automations.py": 34.13, + "tests/integration/test_logger_buffered_recursion_guard.py": 25.77, + "tests/integration/test_loop_disable_enable.py": 14.71, + "tests/integration/test_loop_interval_decoupling.py": 26.16, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47, + "tests/integration/test_lvgl_headless_render.py": 96.36, + "tests/integration/test_micros_to_millis.py": 28.76, + "tests/integration/test_multi_click_trigger.py": 19.8, + "tests/integration/test_multi_device_preferences.py": 38.85, + "tests/integration/test_noise_encryption_key_protection.py": 25.81, + "tests/integration/test_object_id_api_verification.py": 28.46, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14, + "tests/integration/test_object_id_no_friendly_name.py": 18.82, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67, + "tests/integration/test_online_image_bmp.py": 7.41, + "tests/integration/test_oversized_payloads.py": 59.52, + "tests/integration/test_preference_key_stability.py": 27.24, + "tests/integration/test_runtime_stats.py": 20.53, + "tests/integration/test_safe_mode_loop_runs.py": 10.17, + "tests/integration/test_scheduler_blocking_warning.py": 51.45, + "tests/integration/test_scheduler_bulk_cleanup.py": 22.59, + "tests/integration/test_scheduler_defer_cancel.py": 17.64, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.61, + "tests/integration/test_scheduler_defer_fifo_simple.py": 24.73, + "tests/integration/test_scheduler_defer_stress.py": 23.91, + "tests/integration/test_scheduler_heap_stress.py": 25.77, + "tests/integration/test_scheduler_internal_id_no_collision.py": 19.83, + "tests/integration/test_scheduler_interval_reschedule.py": 23.4, + "tests/integration/test_scheduler_interval_zero_coerced.py": 5.11, + "tests/integration/test_scheduler_null_name.py": 17.36, + "tests/integration/test_scheduler_numeric_id_test.py": 20.81, + "tests/integration/test_scheduler_pool.py": 17.42, + "tests/integration/test_scheduler_rapid_cancellation.py": 25.28, + "tests/integration/test_scheduler_recursive_timeout.py": 16.48, + "tests/integration/test_scheduler_removed_item_race.py": 16.14, + "tests/integration/test_scheduler_self_keyed.py": 26.19, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26, + "tests/integration/test_scheduler_string_test.py": 27.09, + "tests/integration/test_script_array_params.py": 3.6, + "tests/integration/test_script_delay_params.py": 24.74, + "tests/integration/test_script_queued.py": 17.21, + "tests/integration/test_script_queued_idle_loop.py": 3.4, + "tests/integration/test_script_wait_on_boot.py": 23.7, + "tests/integration/test_sdl_headless_screenshot.py": 19.53, + "tests/integration/test_select_stringref_trigger.py": 18.93, + "tests/integration/test_sensor_filters_delta.py": 20.85, + "tests/integration/test_sensor_filters_ring_buffer.py": 16.82, + "tests/integration/test_sensor_filters_sliding_window.py": 54.78, + "tests/integration/test_sensor_filters_value_list.py": 19.46, + "tests/integration/test_sensor_timeout_filter.py": 18.39, + "tests/integration/test_set_internal_at_boot.py": 21.69, + "tests/integration/test_snapshot_display.py": 12.64, + "tests/integration/test_socket_wake_gate_tcp.py": 13.08, + "tests/integration/test_status_flags.py": 29.54, + "tests/integration/test_strftime_to.py": 25.62, + "tests/integration/test_syslog.py": 16.25, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18, + "tests/integration/test_template_climate_basic.py": 20.51, + "tests/integration/test_template_climate_custom_modes.py": 27.73, + "tests/integration/test_template_climate_nonoptimistic.py": 26.35, + "tests/integration/test_template_climate_on_control_ordering.py": 26.55, + "tests/integration/test_template_climate_publish_all_fields.py": 17.59, + "tests/integration/test_template_climate_sensor_push.py": 22.04, + "tests/integration/test_template_climate_set_actions.py": 16.82, + "tests/integration/test_template_climate_two_point_temperature.py": 25.13, + "tests/integration/test_template_text_save.py": 25.36, + "tests/integration/test_text_command.py": 18.79, + "tests/integration/test_text_sensor_raw_state.py": 17.07, + "tests/integration/test_uart_mock_ld2410.py": 59.58, + "tests/integration/test_uart_mock_ld2412.py": 59.4, + "tests/integration/test_uart_mock_ld2420.py": 45.27, + "tests/integration/test_uart_mock_ld2450.py": 27.96, + "tests/integration/test_uart_mock_modbus.py": 562.45, + "tests/integration/test_udp.py": 7.48, + "tests/integration/test_use_address_runtime.py": 17.27, + "tests/integration/test_valve_control_action.py": 18.33, + "tests/integration/test_varint_five_byte_device_id.py": 17.59, + "tests/integration/test_wait_until_mid_loop_timing.py": 16.93, + "tests/integration/test_wait_until_on_boot.py": 19.96, + "tests/integration/test_wait_until_ordering.py": 16.19, + "tests/integration/test_wait_until_reentrant_restart.py": 23.67, + "tests/integration/test_wake_loop_forces_phase_b.py": 17.58, + "tests/integration/test_water_heater_template.py": 21.96 } From 651863323b0b67626c91b798dacf51c7e29ac8bd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:40:00 +1000 Subject: [PATCH 035/316] [issues] Add AI usage guidance to the bug report template (#19122) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .github/ISSUE_TEMPLATE/bug_report.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c..2244963a79 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true From 54b8e2e6dc7078657da75414fc8bd5b5967a8c59 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 09:56:14 -0400 Subject: [PATCH 036/316] [audio] Update esp-audio-libs to 4.0.0 (#19300) --- esphome/components/audio/__init__.py | 5 +---- esphome/components/mixer/speaker/mixer_speaker.cpp | 8 ++++---- esphome/components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/idf_component.yml | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2a5304be77..14a0818894 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component( - name="esphome/esp-audio-libs", - ref="3.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") data = _get_data() diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index ef21da65c5..7d33b6c49f 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - esp_audio_libs::ducking::apply(audio_source->mutable_data(), - static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), - samples_to_duck, this->ducking_state_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d1782..494443d695 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool pause_state_{false}; - esp_audio_libs::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index e817a253d9..b3cd5ee09b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 3.2.1 + version: 4.0.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From df481eab006054ff6e7a00541cb762c82915d163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:28:05 +0000 Subject: [PATCH 037/316] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 in /.github/actions/restore-python (#19313) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index ce14b0152a..fa42372ac8 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 358e240d422c0474a170c2d587615cfa565a7224 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:14 -0500 Subject: [PATCH 038/316] Bump github/codeql-action/init from 4.37.9 to 4.38.0 (#19315) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aab3dea592..ca70ec9c11 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 5d89480dbacf4883225a15d9636c2e5960703609 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:53 -0500 Subject: [PATCH 039/316] Bump github/codeql-action/analyze from 4.37.9 to 4.38.0 (#19314) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca70ec9c11..0daae69ccf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:${{matrix.language}}" From 2c0e97421d088e73629972c3c9c5236abfe7e13f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:22 +0000 Subject: [PATCH 040/316] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 (#19312) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 63219a1dbc..c4c1ab072f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173d2c227a..689baa1292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -413,7 +413,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1274,7 +1274,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9100064176..84d5e229d9 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 00dfe0f712419c3df5951a4cd42e82c9bcfd5e2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:33:20 +0000 Subject: [PATCH 041/316] Bump ruff from 0.16.6 to 0.16.7 (#19310) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> 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 95e6f0f73e..0c7600ec12 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.6 + rev: v0.16.7 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index cd0427f33e..010c8243e7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.7 # .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.2 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From e66b59084239910b627fbcc784a93b0263908aec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:01 +0000 Subject: [PATCH 042/316] Bump platformdirs from 4.11.7 to 4.11.8 (#19309) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c73887a39d..1bb8d04ac8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.7 # native esp-idf toolchain global cache dir +platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 5d713ad9ad3478732b1722f2e4d8464ee26ac0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:19 +0000 Subject: [PATCH 043/316] Bump filelock from 3.32.5 to 3.32.6 (#19311) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1bb8d04ac8..bfbf0aa321 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.6 # 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.2 From 588ad529e0e2ad263d131ad34311d2dedcdf67d6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 16:55:08 -0400 Subject: [PATCH 044/316] [i2s_audio] Ramp software volume changes (#19302) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 88 ++++++++----------- .../i2s_audio/speaker/i2s_audio_speaker.h | 25 ++++-- 2 files changed, 55 insertions(+), 58 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 9feaf39fff..daef662a64 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -14,17 +14,19 @@ #include "esp_timer.h" -// esp-audio-libs -#include +#include namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. -// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +// Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in +// [-49.0, 0.0] dB; 0.0 is silence. static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; +// Rate at which the software gain moves toward a new target. +static constexpr uint32_t GAIN_RAMP_MS_PER_DB = 1; + void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -34,9 +36,10 @@ void I2SAudioSpeakerBase::setup() { return; } - // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // Initialize volume control. When audio_dac is configured, this sets the DAC volume and mute state. // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); + this->set_mute_state(this->mute_state_); } void I2SAudioSpeakerBase::dump_config() { @@ -136,6 +139,10 @@ void I2SAudioSpeakerBase::loop() { break; } + // Seed the ramp at the live target so this run adopts it instantly rather than fading to it + // from wherever the previous run left off. Posted here, not in the task: the ramp's mailbox + // allows one writer, and that is the main loop. + this->post_software_gain_(0); xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); @@ -153,50 +160,31 @@ void I2SAudioSpeakerBase::loop() { } void I2SAudioSpeakerBase::set_volume(float volume) { - this->volume_ = volume; -#ifdef USE_AUDIO_DAC - if (this->audio_dac_ != nullptr) { - if (volume > 0.0f) { - this->audio_dac_->set_mute_off(); - } - this->audio_dac_->set_volume(volume); - } else -#endif // USE_AUDIO_DAC - { - // Fallback to software volume control by using a Q31 fixed point scaling factor. - // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely - // and avoid any floating-point precision issues that could cause slight volume reduction. - if (volume >= 1.0f) { - this->q31_volume_factor_ = INT32_MAX; - } else if (volume <= 0.0f) { - this->q31_volume_factor_ = 0; - } else { - this->q31_volume_factor_ = - esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); - } - } + speaker::Speaker::set_volume(volume); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); } void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { - this->mute_state_ = mute_state; + speaker::Speaker::set_mute_state(mute_state); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); +} + +void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } else -#endif // USE_AUDIO_DAC - { - if (mute_state) { - // Fallback to software volume control and scale by 0 - this->q31_volume_factor_ = 0; - } else { - // Revert to previous volume when unmuting - this->set_volume(this->volume_); - } + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp stays at unity } +#endif // USE_AUDIO_DAC + // Software volume control. The ramp treats 0 dB as unity and skips processing there. + float target_db; + if (this->mute_state_ || this->volume_ <= 0.0f) { + target_db = -INFINITY; + } else if (this->volume_ >= 1.0f) { + target_db = 0.0f; + } else { + target_db = remap(this->volume_, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f); + } + this->gain_ramp_.set_target_db_at_rate(target_db, rate_samples); } size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { @@ -355,14 +343,14 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s } void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q31_volume_factor_ == INT32_MAX) { - return; // Max volume, no processing needed +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp is never targeted } - +#endif // USE_AUDIO_DAC const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + this->gain_ramp_.process(data, static_cast(bytes_per_sample), + this->current_stream_info_.bytes_to_samples(bytes_read)); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index adb6ca5e3f..5812cc211b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,6 +16,8 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include // esp-audio-libs + namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations @@ -77,19 +79,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool has_buffered_data() const override; - /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to convert the volume to a dB target for + /// the gain ramp. /// @param volume between 0.0 and 1.0 void set_volume(float volume) override; - /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Mutes or unmutes the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to post the mute state to the gain ramp. /// @param mute_state true for muting, false for unmuting void set_mute_state(bool mute_state) override; protected: + /// @brief Posts the ramp target derived from the current volume and mute state. No-op when an audio dac owns + /// volume. Main loop only. + /// @param rate_samples Samples the ramp takes per dB of change; 0 adopts the target at once + void post_software_gain_(uint32_t rate_samples); + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); @@ -128,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} - /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the + /// speaker task only. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); @@ -155,7 +162,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int32_t q31_volume_factor_{INT32_MAX}; + // Smooths software gain changes. The main loop posts targets, the speaker task processes; + // GainRamp's mailbox makes that safe. The main loop is the only poster. + esp_audio_libs::gain::GainRamp gain_ramp_; audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as From 328077c4f890923db9cd538101dc99ed8db89ad4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 14 Sep 2026 16:16:14 -0500 Subject: [PATCH 045/316] [tinyusb] Add on_mount/on_unmount triggers and is_mounted condition (#19067) Co-authored-by: Claude Fable 5.1 --- esphome/components/tinyusb/__init__.py | 52 ++++++++++++++++++- .../components/tinyusb/tinyusb_component.cpp | 34 +++++++++++- .../components/tinyusb/tinyusb_component.h | 27 ++++++++++ tests/components/tinyusb/common.yaml | 9 ++++ .../components/tinyusb/test.esp32-p4-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s2-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s3-idf.yaml | 8 ++- 7 files changed, 141 insertions(+), 5 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 53c4ab0073..7ad88d3018 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -12,17 +12,22 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -33,6 +38,20 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -44,6 +63,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +124,28 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +@automation.register_condition( + "tinyusb.is_mounted", + IsMountedCondition, + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), +) +async def tinyusb_is_mounted_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb..3fab9de008 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21..f7f574ec6d 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,8 +1,11 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +26,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +48,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +63,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), @@ -69,6 +87,15 @@ class TinyUSB final : public Component { }; }; +template class IsMountedCondition final : public Condition { + public: + explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_mounted(); } + + protected: + TinyUSB *parent_; +}; + } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index 674e89dbe8..32db1999af 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,6 +6,15 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + on_mount: + - logger.log: USB host mounted + - if: + condition: + tinyusb.is_mounted: + then: + - logger.log: USB host is mounted + on_unmount: + - logger.log: USB host unmounted # tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. usb_cdc_acm: diff --git a/tests/components/tinyusb/test.esp32-p4-idf.yaml b/tests/components/tinyusb/test.esp32-p4-idf.yaml index dade44d145..7a37fcf41b 100644 --- a/tests/components/tinyusb/test.esp32-p4-idf.yaml +++ b/tests/components/tinyusb/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index 09b98ada40..67ea24f2c6 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1,4 +1,10 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 # S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared # USB OTG peripheral; route the logger to UART0 so the fixture builds. diff --git a/tests/components/tinyusb/test.esp32-s3-idf.yaml b/tests/components/tinyusb/test.esp32-s3-idf.yaml index dade44d145..7a37fcf41b 100644 --- a/tests/components/tinyusb/test.esp32-s3-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 From 2472838e130fe597f7326a0ec94712b92acb8b15 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 17:46:30 -0400 Subject: [PATCH 046/316] [speaker][speaker_source] Make a volume of zero silent with an audio DAC (#19305) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/speaker/speaker.h | 34 ++++++++++++++----- .../speaker_source_media_player.cpp | 2 +- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index daef662a64..0c1140da0c 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -177,7 +177,7 @@ void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #endif // USE_AUDIO_DAC // Software volume control. The ramp treats 0 dB as unity and skips processing there. float target_db; - if (this->mute_state_ || this->volume_ <= 0.0f) { + if (this->is_silent_()) { target_db = -INFINITY; } else if (this->volume_ >= 1.0f) { target_db = 0.0f; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fe994f440d..f40d0f4a1a 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index c89b6c588c..01e9ca042e 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -18,6 +18,9 @@ namespace esphome::speaker { +/// Volumes below this are treated as zero +static constexpr float SILENT_VOLUME_THRESHOLD = 0.001f; + enum State : uint8_t { STATE_STOPPED = 0, STATE_STARTING, @@ -65,13 +68,15 @@ class Speaker { bool is_running() const { return this->state_ == STATE_RUNNING; } bool is_stopped() const { return this->state_ == STATE_STOPPED; } - // Volume control is handled by a configured audio dac component. Individual speaker components can - // override and implement in software if an audio dac isn't available. + // Volume and mute are independent: changing one never alters the other's stored state. Volume control is + // handled by a configured audio dac component. Individual speaker components can override and implement in + // software if an audio dac isn't available. virtual void set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { this->audio_dac_->set_volume(volume); + this->apply_audio_dac_mute_(); } #endif }; @@ -80,13 +85,7 @@ class Speaker { virtual void set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } + this->apply_audio_dac_mute_(); #endif } virtual bool get_mute_state() { return this->mute_state_; } @@ -110,6 +109,23 @@ class Speaker { } protected: + /// @brief Whether the output should be silent: muted, or the volume is effectively zero. + /// Volume steps from media players can leave a positive value near float epsilon instead of exactly zero. + bool is_silent_() const { return this->mute_state_ || this->volume_ < SILENT_VOLUME_THRESHOLD; } + +#ifdef USE_AUDIO_DAC + /// @brief Uses the audio dac's mute as the silence mechanism, since a dac's minimum volume is often audible. + void apply_audio_dac_mute_() { + if (this->audio_dac_ == nullptr) + return; + if (this->is_silent_()) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } +#endif + State state_{STATE_STOPPED}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a33a1a1650..661146ee49 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); From 7a25ca074156203c979ac268f8248b90989c31d4 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Mon, 14 Sep 2026 17:11:25 -0500 Subject: [PATCH 047/316] [speaker_source] Don't remap a requested volume of zero (#19063) Co-authored-by: rexmoriarty <181678468+rexmoriarty@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 661146ee49..cee203a699 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -809,8 +809,11 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); for (auto &ps : this->pipelines_) { if (ps.is_configured()) { From 0af1f22e77985a91c229b8da6ca27f461ea7f993 Mon Sep 17 00:00:00 2001 From: Carrie Watts Date: Tue, 15 Sep 2026 00:57:57 +0200 Subject: [PATCH 048/316] [speaker_source] keep mute when volume changes (#18412) Signed-off-by: carriewattsmake Co-authored-by: carriewattsmake Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index cee203a699..215f3942d5 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -832,15 +832,6 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { } } - // Turn on the mute state if the volume is effectively zero, off otherwise. - // Pass publish=false to avoid saving twice. - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true, false); - } else { - this->set_mute_state_(false, false); - } - - // Save after mute mutation so the restored state has the correct is_muted_ value if (publish) { this->save_volume_restore_state_(); } From 8d61b35cdc5063fd6e267d85667963d98ec17fed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 18:59:05 -0400 Subject: [PATCH 049/316] [speaker] Make media player volume and mute independent (#19307) --- .../speaker/media_player/speaker_media_player.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index f40d0f4a1a..9ce50d7b76 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -595,8 +595,11 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit with in the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); if (this->media_speaker_ != nullptr) { this->media_speaker_->set_volume(bounded_volume); @@ -611,13 +614,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->save_volume_restore_state_(); } - // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true); - } else { - this->set_mute_state_(false); - } - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } From e737dc8daca325f459f0f8045d0fcc0c055761fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:33:24 +0000 Subject: [PATCH 050/316] Synchronise Device Classes from Home Assistant (#19318) --- esphome/components/binary_sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 9ef7efc96a..a114ab4205 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -81,6 +82,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, diff --git a/esphome/const.py b/esphome/const.py index e1d875f94b..fd95df4196 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1345,6 +1345,7 @@ DEVICE_CLASS_GARAGE = "garage" DEVICE_CLASS_GARAGE_DOOR = "garage_door" DEVICE_CLASS_GAS = "gas" DEVICE_CLASS_GATE = "gate" +DEVICE_CLASS_GLASS_BREAK = "glass_break" DEVICE_CLASS_HEAT = "heat" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_IDENTIFY = "identify" From e163ae5299a4bcb61a904b450b8d38ca32895a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 051/316] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb5954..38cba56c62 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 81b90766b9..a1a3436d47 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -690,7 +690,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -864,6 +873,8 @@ async def wifi_roam_to_code( KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -896,6 +907,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 0000000000..87f599c66e --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 0000000000..6973e6e26f --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 0000000000..2b4200968a --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 0000000000..20b69b6c64 --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From 6362ae71c09c29297c0b08c925be1cd15dd3076b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 052/316] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed70..0a34ed037d 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# 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. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# 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. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a82..037901a873 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d..1623c099a3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb91066..66ddcd7722 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9..4c1d11da83 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e..aa72a08a60 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb..b8d06d3d5a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31..741d4f6f00 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af..242e2eea21 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d7..c964ced987 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab..6fc8c8331c 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a3..2c5b92b810 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50..1eafb13166 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # 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 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825..fcccae144e 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 0000000000..01bdacbf86 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f..592f6c12ba 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5..3bdfa094e0 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5c..ce2965e449 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 0000000000..d6a29d7175 --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb7..b488e51f3c 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 0000000000..49e89eaa20 --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 457bb3ecc948a250a66497915154723342bb8e24 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 053/316] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a..ab76995412 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db3..727a4c8c1e 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From f19403192da7098c0b05acc9dacd20fffac22e29 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 11:47:06 -0500 Subject: [PATCH 054/316] [usb_uart] Add claim_comm_interface option (#18969) --- esphome/components/usb_uart/__init__.py | 53 +++++++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 21 ++++++---- esphome/components/usb_uart/usb_uart.h | 4 ++ tests/components/usb_uart/common.yaml | 1 + 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index edbf75f70f..5d0f8be165 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -44,6 +44,7 @@ UART_STOP_BITS_OPTIONS = { } DEFAULT_BAUD_RATE = 9600 +CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" class Type: @@ -56,6 +57,7 @@ class Type: max_channels: int = 1, baud_rate_required: bool = True, max_baud: int = 1_000_000, + has_comm_interface: bool = False, ) -> None: self.name = name cls = cls or name @@ -65,6 +67,9 @@ class Type: self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud + # True for types that claim the CDC comm (interrupt) interface; only these + # accept the claim_comm_interface option. + self.has_comm_interface = has_comm_interface @property def max_channels(self) -> int: @@ -80,11 +85,21 @@ class Type: uart_types = ( - Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type( + "CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False, has_comm_interface=True + ), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type( + "ESP_JTAG", + 0x303A, + 0x1001, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), @@ -95,12 +110,20 @@ uart_types = ( Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type( + "STM32_VCP", + 0x0483, + 0x5740, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), ) def channel_schema(type_: "Type") -> cv.Schema: - return cv.Schema( + schema = cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( cv.ensure_list( @@ -139,9 +162,26 @@ def channel_schema(type_: "Type") -> cv.Schema: max=type_.max_channels, msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", ), - ) + ), } ) + if type_.has_comm_interface: + # The comm (interrupt) interface pins a host hardware channel per device; + # disable to save one on channel-poor hosts (some devices may need it + # claimed before enabling data flow). + schema = schema.extend( + {cv.Optional(CONF_CLAIM_COMM_INTERFACE, default=True): cv.boolean} + ) + else: + schema = schema.extend( + { + cv.Optional(CONF_CLAIM_COMM_INTERFACE): cv.invalid( + f"'{CONF_CLAIM_COMM_INTERFACE}' is only supported on device types " + f"that claim the CDC comm interface; {type_.name} never claims it" + ) + } + ) + return schema CONFIG_SCHEMA = cv.ensure_list( @@ -172,6 +212,9 @@ async def to_code(config: list[ConfigType]) -> None: for device in config: var = await register_usb_client(device) + # The C++ default is true; only emit the override + if not device.get(CONF_CLAIM_COMM_INTERFACE, True): + cg.add(var.set_claim_comm_interface(False)) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 60b7fe4e9c..3113f695f6 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -431,15 +431,20 @@ void USBUartTypeCdcAcm::on_connected() { // they enable data flow on the bulk endpoints. if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { - auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, - channel->cdc_dev_.interrupt_interface_number, 0); - if (err_comm != ESP_OK) { - // Continue anyway: the interface number stays valid for CDC request addressing - ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, - esp_err_to_name(err_comm)); + if (!this->claim_comm_interface_) { + ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", + channel->cdc_dev_.interrupt_interface_number); } else { - ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_claimed = true; + auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, + channel->cdc_dev_.interrupt_interface_number, 0); + if (err_comm != ESP_OK) { + // Continue anyway: the interface number stays valid for CDC request addressing + ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, + esp_err_to_name(err_comm)); + } else { + ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; + } } } auto err = diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9d87bf964c..22563209da 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -271,12 +271,16 @@ class USBUartComponent : public usb_host::USBClient { class USBUartTypeCdcAcm : public USBUartComponent { public: USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + void set_claim_comm_interface(bool claim) { this->claim_comm_interface_ = claim; } protected: virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + // Each claimed interface pins one host hardware channel per endpoint; skipping + // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). + bool claim_comm_interface_{true}; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5b23f9d685..2e41fad1a1 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -6,6 +6,7 @@ usb_uart: type: cdc_acm vid: 0x1234 pid: 0x5678 + claim_comm_interface: false channels: - id: channel_0_1 - id: uart_1 From 565dc8092c67aa7651086d3e37c7de70b7661d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:42:52 -0500 Subject: [PATCH 055/316] Update tzdata requirement from >=2026.3 to >=2026.4 (#19328) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfbf0aa321..15ee7af7c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.3 # from time +tzdata>=2026.4 # from time pyserial==3.5 platformio==6.1.19 esptool==5.4.0 From 57860dc06c39b479e3167bf36361149c3b42cb4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Sep 2026 16:10:46 -0500 Subject: [PATCH 056/316] [core] Add register_simple_action and register_parented_action helpers (#19321) --- AGENTS.md | 15 +++- esphome/automation.py | 127 ++++++++++++++++++++++------ tests/unit_tests/test_automation.py | 125 ++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db3cd3d62..448bf49114 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,7 +431,17 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + Register it without writing a builder: + ```python + automation.register_simple_action( + "my_component.do_something", MyAction, schema, synchronous=True + ) + ``` + The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a + no-argument constructor, `register_parented_action` for a class deriving from `Parented`, and + the `@automation.register_action(...)` decorator only when the builder must also set fields. + + Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. * **Conditions:** ```cpp @@ -443,7 +453,8 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; + `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. diff --git a/esphome/automation.py b/esphome/automation.py index 1689d29c42..3ffda50c81 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -102,6 +102,101 @@ def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schem return CONDITION_REGISTRY.register(name, condition_type, schema) +async def _build_with_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(automation_id, template_arg, parent) + + +async def _build_without_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(automation_id, template_arg) + + +async def _build_parented( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(automation_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +def register_simple_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes the object named by ``config[CONF_ID]``. + + Use the ``register_action`` decorator instead when the builder must also set fields. + """ + register_action(name, action_type, schema, synchronous=synchronous)( + _build_with_parent + ) + + +def register_simple_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_simple_action``.""" + register_condition(name, condition_type, schema)(_build_with_parent) + + +def register_bare_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes no arguments.""" + register_action(name, action_type, schema, synchronous=synchronous)( + _build_without_parent + ) + + +def register_bare_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_bare_action``.""" + register_condition(name, condition_type, schema)(_build_without_parent) + + +def register_parented_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action deriving from ``Parented``. + + The object is constructed without arguments and ``set_parent()`` receives the object + named by ``config[CONF_ID]``. + """ + register_action(name, action_type, schema, synchronous=synchronous)(_build_parented) + + +def register_parented_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_parented_action``.""" + register_condition(name, condition_type, schema)(_build_parented) + + Action = cg.esphome_ns.class_("Action") Trigger = cg.esphome_ns.class_("Trigger") ACTION_REGISTRY = Registry() @@ -534,44 +629,20 @@ async def lambda_action_to_code( return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) -@register_action( +register_simple_action( "component.update", UpdateComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) -@register_action( +register_simple_action( "component.suspend", SuspendComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_suspend_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) @register_action( diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index a377cf185a..07ea753360 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,7 +1,9 @@ """Tests for esphome.automation module.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, call, patch +from collections.abc import Callable, Generator +from functools import partial +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -12,9 +14,18 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + register_bare_action, + register_bare_condition, + register_parented_action, + register_parented_condition, + register_simple_action, + register_simple_condition, ) +import esphome.codegen as cg +from esphome.const import CONF_ID +from esphome.core import ID from esphome.cpp_generator import MockObj, RawExpression -from esphome.util import RegistryEntry +from esphome.util import Registry, RegistryEntry def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]: @@ -475,3 +486,111 @@ async def test_build_callback_automations_defaults( mock_build_callback.assert_called_once_with( parent, "add_on_press_callback", [], conf, forwarder=None ) + + +PARENT_ID = ID("my_component") +PARENT_OBJ = MockObj("parent", "->") +NEW_OBJ = MockObj("var", "->") +ACTION_TYPE = cg.esphome_ns.class_("MyAction") +CONDITION_TYPE = cg.esphome_ns.class_("MyCondition") +TEMPLATE_ARG = cg.TemplateArguments() + + +class MockCodegen(NamedTuple): + get_variable: AsyncMock + new_pvariable: MagicMock + register_parented: AsyncMock + + +@pytest.fixture +def mock_cg() -> Generator[MockCodegen]: + """Patch the codegen calls the shared builders make.""" + with ( + patch("esphome.codegen.get_variable", new_callable=AsyncMock) as get_variable, + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch( + "esphome.codegen.register_parented", new_callable=AsyncMock + ) as register_parented, + ): + get_variable.return_value = PARENT_OBJ + new_pvariable.return_value = NEW_OBJ + yield MockCodegen(get_variable, new_pvariable, register_parented) + + +@pytest.fixture +def registries() -> Generator[tuple[Registry, Registry]]: + """Patch both registries so registrations made by a test do not leak.""" + actions = Registry() + conditions = Registry() + with ( + patch("esphome.automation.ACTION_REGISTRY", actions), + patch("esphome.automation.CONDITION_REGISTRY", conditions), + ): + yield actions, conditions + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register", "is_action", "ctor_parent", "parented"), + [ + (partial(register_simple_action, synchronous=True), True, True, False), + (partial(register_bare_action, synchronous=True), True, False, False), + (partial(register_parented_action, synchronous=True), True, False, True), + (register_simple_condition, False, True, False), + (register_bare_condition, False, False, False), + (register_parented_condition, False, False, True), + ], + ids=[ + "simple_action", + "bare_action", + "parented_action", + "simple_condition", + "bare_condition", + "parented_condition", + ], +) +async def test_shared_builders( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + register: Callable[..., None], + is_action: bool, + ctor_parent: bool, + parented: bool, +) -> None: + """Each helper constructs the object and wires the parent the way its C++ shape needs.""" + actions, conditions = registries + type_id = ACTION_TYPE if is_action else CONDITION_TYPE + register("my.entry", type_id, {}) + entry = (actions if is_action else conditions)["my.entry"] + assert entry.type_id is type_id + config = {CONF_ID: PARENT_ID} if ctor_parent or parented else {} + + result = await entry.fun(config, ID("obj_1"), TEMPLATE_ARG, []) + + assert result is NEW_OBJ + if ctor_parent: + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.new_pvariable.assert_called_once_with( + ID("obj_1"), TEMPLATE_ARG, PARENT_OBJ + ) + else: + mock_cg.get_variable.assert_not_called() + mock_cg.new_pvariable.assert_called_once_with(ID("obj_1"), TEMPLATE_ARG) + if parented: + mock_cg.register_parented.assert_awaited_once_with(NEW_OBJ, PARENT_ID) + else: + mock_cg.register_parented.assert_not_called() + + +@pytest.mark.parametrize("synchronous", [True, False]) +def test_shared_builders_keep_synchronous_flag( + registries: tuple[Registry, Registry], synchronous: bool +) -> None: + """The synchronous flag reaches the registry entry unchanged.""" + actions, _ = registries + register_simple_action("my.simple", ACTION_TYPE, {}, synchronous=synchronous) + register_bare_action("my.bare", ACTION_TYPE, {}, synchronous=synchronous) + register_parented_action("my.parented", ACTION_TYPE, {}, synchronous=synchronous) + assert actions["my.simple"].synchronous is synchronous + assert actions["my.bare"].synchronous is synchronous + assert actions["my.parented"].synchronous is synchronous From 20d199ae2066f1bb723d56b86643fe46608ffea0 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sun, 13 Sep 2026 18:18:52 -0700 Subject: [PATCH 057/316] [pmsa003i] Fix read from uninitialized stack memory (#19053) --- esphome/components/pmsa003i/pmsa003i.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e879..0b5c72a94d 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI From b7acd9c0dc4a390a0369579d46e4ef5e6d3d2e5c Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 13 Sep 2026 18:36:39 -0700 Subject: [PATCH 058/316] [template] Stop water heater republishing when a temperature is unknown (#19013) --- .../water_heater/template_water_heater.cpp | 10 ++++-- ...r_heater_template_unknown_temperature.yaml | 16 +++++++++ .../integration/test_water_heater_template.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/water_heater_template_unknown_temperature.yaml diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca..9d6a3523d2 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 0000000000..a70ed25bd7 --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d6984..3d7f885160 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) From 076dc017ab05b54bb9a5f52fcd266774aec2731e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 059/316] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f..9ef7efc96a 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f14..3e7d345805 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc..766cdc4afb 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c..3b632a1847 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b7..5c8d71696f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index d4b39c029b..61b687d787 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -285,7 +285,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -484,7 +486,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 0000000000..f7e0374319 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 8c999d3152c13c622666e328bb98fcf50a949c24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 060/316] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f6..fc0893323b 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9b..3c7942b9a3 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 0000000000..b3eae34436 --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 0000000000..b33508602a --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 6582c618f1469940b1a4e88418b15e3834375b63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 061/316] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f7..2459163786 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f..72d3bf75b1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 0000000000..dae1c48883 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 0000000000..2d37d7ae19 --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 0000000000..3c34da43ac --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 0000000000..2b13ed966b --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From eae6af437bae1253cf4dbf85afc14c4a7d62a4fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 062/316] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5..10d5e5eb59 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f423572..57c8c553f6 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 0000000000..84c5eafc5a --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 0000000000..682ae9de51 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 0000000000..172715aef0 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a..8fa62c0636 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 81be397056c7edd6e2e506d0e887fcea8bb07dd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 063/316] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a..ab9624c364 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368..eafa161f51 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 0000000000..ecb33b0ea8 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 0000000000..a4fc24a7cb --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From 808b7210db14d8610c653605d31eff86a626b29d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 064/316] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 61b687d787..418e1a4979 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -167,6 +167,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -493,7 +496,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -603,7 +606,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -652,7 +656,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + 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") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -668,10 +674,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 16b62a5bb0..8bf4581413 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -913,11 +913,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + 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 #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 0000000000..94e5de47a0 --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 0000000000..068479a540 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 0000000000..1b5e7d7dba --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 0000000000..b326f3eaee --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From f8bda9fbad897d10aadf847ea2fd03fea20cb125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 065/316] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b03084..138db75ad1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7e..ae55f4145a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7b..4ce30afb94 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 0000000000..1f11ea4580 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 0000000000..dc25fe99ce --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 0000000000..ce45a6ae3f --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From a1ad794d036976c3122d735ec7cffa82d8e0fb29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 066/316] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6..79c2e50984 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e04..be6e63b154 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 0000000000..9ffc8af736 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 0000000000..771e513345 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 45362dbc5b6ca982f0d1747bd2d2239461de89da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 067/316] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb5954..38cba56c62 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 418e1a4979..64eef46f74 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -681,7 +681,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -843,6 +852,8 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -875,6 +886,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 0000000000..87f599c66e --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 0000000000..6973e6e26f --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 0000000000..2b4200968a --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 0000000000..20b69b6c64 --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From fe0f04b2e434cda731b732e88e59590a65b7f849 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 068/316] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed70..0a34ed037d 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# 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. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# 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. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a82..037901a873 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d..1623c099a3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb91066..66ddcd7722 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9..4c1d11da83 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e..aa72a08a60 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb..b8d06d3d5a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31..741d4f6f00 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af..242e2eea21 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d7..c964ced987 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab..6fc8c8331c 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a3..2c5b92b810 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50..1eafb13166 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # 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 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825..fcccae144e 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 0000000000..01bdacbf86 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f..592f6c12ba 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5..3bdfa094e0 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5c..ce2965e449 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 0000000000..d6a29d7175 --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb7..b488e51f3c 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 0000000000..49e89eaa20 --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 0f500628dd001e0e4c0ec01921c2113925cad178 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 069/316] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a..ab76995412 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db3..727a4c8c1e 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From 3f7725a6847b15696dc889b79d032f7a524e02c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:00:05 +1200 Subject: [PATCH 070/316] Bump version to 2026.9.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 331d2f7984..a5ed253819 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b4 +PROJECT_NUMBER = 2026.9.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 5696125355..8a9e969585 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b4" +__version__ = "2026.9.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ec1d8fa9535ec51a69ac18592bd2e6b66bbf7f06 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 15 Sep 2026 21:06:40 -0400 Subject: [PATCH 071/316] [mdns] Add runtime service enable/disable API (ESP32 only) (#19325) --- esphome/components/mdns/__init__.py | 24 +++++++ esphome/components/mdns/mdns_component.h | 16 +++++ esphome/components/mdns/mdns_esp32.cpp | 69 ++++++++++++++---- esphome/core/defines.h | 3 + tests/component_tests/mdns/__init__.py | 0 .../mdns/test_service_enable_disable.py | 70 +++++++++++++++++++ 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/mdns/__init__.py create mode 100644 tests/component_tests/mdns/test_service_enable_disable.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index c8020104b3..0fb24fdf1d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb99..ed06b8e133 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -63,6 +63,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +115,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd7..48df61326e 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f6010fd7fa..b36d39bbef 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -479,6 +479,9 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#ifndef USE_OPENTHREAD +#define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) diff --git a/tests/component_tests/mdns/__init__.py b/tests/component_tests/mdns/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py new file mode 100644 index 0000000000..eacf722f17 --- /dev/null +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -0,0 +1,70 @@ +"""request_service_enable_disable() only opts in on platforms whose mDNS stack +can add and remove services after setup, and tells the caller so.""" + +import pytest + +from esphome.components import mdns +from esphome.const import CONF_DISABLED, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def _set_config( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + config: dict, +) -> None: + set_core_config(platform_framework) + CORE.config = config + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO], +) +def test_esp32_adds_define_and_keeps_services_stored( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is True + # Disabled services must stay stored so they can be re-registered later. + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], +) +def test_other_platforms_return_false( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="no_mdns"), + pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"), + pytest.param( + {"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread" + ), + ], +) +def test_esp32_returns_false_when_services_cannot_be_toggled( + set_core_config: SetCoreConfigCallable, config: dict +) -> None: + _set_config(set_core_config, PlatformFramework.ESP32_IDF, config) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() From d824a32ef1f77c8be80e3d5800c2ca8848585a49 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 20:32:15 -0500 Subject: [PATCH 072/316] [uart_mux] New component to share a UART between a CDC-ACM bridge and local consumers (#19066) Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 1 + esphome/components/uart_mux/__init__.py | 84 ++++++++++++ esphome/components/uart_mux/uart_mux.cpp | 127 ++++++++++++++++++ esphome/components/uart_mux/uart_mux.h | 109 +++++++++++++++ script/analyze_component_buses.py | 1 + tests/component_tests/uart_mux/__init__.py | 0 tests/component_tests/uart_mux/test_init.py | 42 ++++++ tests/components/uart_mux/common.yaml | 44 ++++++ .../uart_mux/test.esp32-p4-idf.yaml | 2 + .../uart_mux/test.esp32-s2-idf.yaml | 7 + .../uart_mux/test.esp32-s3-idf.yaml | 2 + 12 files changed, 420 insertions(+) create mode 100644 esphome/components/uart_mux/__init__.py create mode 100644 esphome/components/uart_mux/uart_mux.cpp create mode 100644 esphome/components/uart_mux/uart_mux.h create mode 100644 tests/component_tests/uart_mux/__init__.py create mode 100644 tests/component_tests/uart_mux/test_init.py create mode 100644 tests/components/uart_mux/common.yaml create mode 100644 tests/components/uart_mux/test.esp32-p4-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s2-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 246a210c7c..044d005119 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -589,6 +589,7 @@ esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow +esphome/components/uart_mux/* @kbx81 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h index 64522c86bd..405b794653 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -30,6 +30,7 @@ class CDCACMUARTBridge final : public Component { void set_line_coding(); void set_line_state(bool dtr, bool rts); + uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; } /** * Stop forwarding in both directions and hand the UART back to its configured diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py new file mode 100644 index 0000000000..6c479f0cdd --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -0,0 +1,84 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DOMAIN = "uart_mux" +DEPENDENCIES = ["bridge", "uart"] +MULTI_CONF = True + +CONF_BRIDGE_ID = "bridge_id" +CONF_INITIAL_ROUTE = "initial_route" +ROUTE_BRIDGE = "bridge" +ROUTE_LOCAL = "local" + +uart_mux_ns = cg.esphome_ns.namespace("uart_mux") +UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) +SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) +SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) +IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UARTMux), + cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge), + cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of( + ROUTE_BRIDGE, ROUTE_LOCAL, lower=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Two muxes on one bridge would each believe they own the bus. + owned = fv.full_config.get().data.setdefault(DOMAIN, set()) + bridge_id = str(config[CONF_BRIDGE_ID]) + if bridge_id in owned: + raise cv.Invalid( + f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; " + "each bridge supports one mux.", + [CONF_BRIDGE_ID], + ) + owned.add(bridge_id) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + bridge = await cg.get_variable(config[CONF_BRIDGE_ID]) + var = cg.new_Pvariable(config[CONF_ID], bridge) + await cg.register_component(var, config) + if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL: + cg.add(var.set_start_local(True)) + + +UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(UARTMux)} +) + + +automation.register_simple_action( + "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True +) +automation.register_simple_action( + "uart_mux.select_bridge", + SelectBridgeAction, + UART_MUX_ACTION_SCHEMA, + synchronous=True, +) +automation.register_simple_condition( + "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 0000000000..df2ee5ccf5 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -0,0 +1,127 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "uart_mux.h" +#include "esphome/core/log.h" + +#include "driver/uart.h" + +namespace esphome::uart_mux { + +static const char *const TAG = "uart_mux"; + +void UARTMux::setup() { + // A failed UART never assigned its port; nothing behind the mux can work. + if (this->uart_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->settings_ = { + this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(), + this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(), + this->uart_->get_parity(), + }; + this->apply_settings_(); + + if (this->start_local_) { + this->select_local(); + } else { + // loop() only completes hand-offs; the bridge keeps the UART until an action. + this->disable_loop(); + } +} + +void UARTMux::loop() { + if (!this->bridge_->is_paused()) { + return; + } + // Bytes that arrived during the hand-off belong to neither owner. + this->flush_input_(); + this->route_ = Route::ROUTE_LOCAL; + ESP_LOGD(TAG, "UART routed to local consumers"); + this->disable_loop(); +} + +void UARTMux::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Mux:\n" + " Start local: %s\n" + " Route: %s", + YESNO(this->start_local_), + this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local") + : this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local") + : LOG_STR_LITERAL("bridge")); +} + +void UARTMux::load_settings(bool dump_config) { + if (!this->load_settings_warned_) { + this->load_settings_warned_ = true; + ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead"); + } + // Undo whatever the caller set on us. Not re-sampled from the live UART, whose + // fields carry the host's line coding while the bridge owns the bus. + this->apply_settings_(); +} + +void UARTMux::apply_settings_() { + this->baud_rate_ = this->settings_.baud_rate; + this->data_bits_ = this->settings_.data_bits; + this->stop_bits_ = this->settings_.stop_bits; + this->parity_ = this->settings_.parity; + this->rx_full_threshold_ = this->settings_.rx_full_threshold; + this->rx_timeout_ = this->settings_.rx_timeout; + this->rx_buffer_size_ = this->settings_.rx_buffer_size; +} + +void UARTMux::select_local() { + if (this->route_ != Route::ROUTE_BRIDGE) { + return; + } + ESP_LOGD(TAG, "Pausing bridge to route UART locally"); + this->bridge_->pause(); + this->route_ = Route::ROUTE_PENDING_LOCAL; + this->enable_loop(); +} + +void UARTMux::select_bridge() { + if (this->route_ == Route::ROUTE_BRIDGE) { + return; + } + // A bridge that failed setup() has no worker tasks; handing it the bus would kill + // the UART in both directions. + if (this->bridge_->is_failed()) { + ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally"); + return; + } + // While the pause is still pending the bridge's RX task may be inside + // uart_read_bytes() on this port, and nothing local has run, so flush only a + // completed hand-off. + if (this->route_ == Route::ROUTE_LOCAL) { + this->flush_input_(); + } + this->route_ = Route::ROUTE_BRIDGE; + ESP_LOGD(TAG, "UART routed to bridge"); + this->bridge_->resume(); + this->disable_loop(); +} + +void UARTMux::flush_input_() { + // Drain the UART component's one-byte peek cache first: the driver flush does not + // clear it, and draining afterwards could discard a freshly arrived byte instead. + uint8_t discard; + if (this->uart_->available() > 0) { + this->uart_->read_byte(&discard); + } + uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); +} + +void UARTMux::write_array(const uint8_t *data, size_t len) { + if (!this->is_local()) { + ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); + return; + } + this->uart_->write_array(data, len); +} + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 0000000000..1eb2374714 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -0,0 +1,109 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::uart_mux { + +/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local +/// consumers bind to the mux as their UART; it forwards to the hardware UART only +/// while routed locally and reports the route through is_connected(). Routing is +/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount. +class UARTMux final : public uart::UARTComponent, public Component { + public: + explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {} + + void setup() override; + void loop() override; + void dump_config() override; + // Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the + // mirrored framing must exist before anything reads it from us. + float get_setup_priority() const override { return setup_priority::BUS - 0.5f; } + + /// Route locally at boot instead of leaving the UART with the bridge. + void set_start_local(bool start_local) { this->start_local_ = start_local; } + + /// Pause the bridge and route the UART to local consumers once it has stopped. + void select_local(); + /// Route the UART back to the bridge. + void select_bridge(); + bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; } + + // uart::UARTComponent: forwarded while routed locally, inert otherwise. + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); } + bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); } + size_t available() override { return this->is_local() ? this->uart_->available() : 0; } + uart::UARTFlushResult flush() override { + return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + bool is_connected() override { return this->is_local(); } + // Ignored: the bridge's tasks block inside the driver, and reinstalling it would + // pull it out from under them. The framing is the hardware UART's to change. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; + + protected: + enum class Route : uint8_t { + ROUTE_BRIDGE, + ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus + ROUTE_LOCAL, + }; + + // The hardware UART's settings as configured. Taken once at setup, before the + // bridge can overwrite the live fields with a host's line coding. + struct Settings { + uint32_t baud_rate; + size_t rx_full_threshold; + size_t rx_timeout; + size_t rx_buffer_size; + uint8_t data_bits; + uint8_t stop_bits; + uart::UARTParityOptions parity; + }; + + void check_logger_conflict() override {} + void flush_input_(); + // Publish settings_ through the UARTComponent getters. + void apply_settings_(); + + uart::IDFUARTComponent *uart_; + cdc_acm_uart::CDCACMUARTBridge *bridge_; + Settings settings_{}; + Route route_{Route::ROUTE_BRIDGE}; + bool start_local_{false}; + bool load_settings_warned_{false}; +}; + +template class SelectLocalAction final : public Action { + public: + explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_local(); } + + protected: + UARTMux *parent_; +}; + +template class SelectBridgeAction final : public Action { + public: + explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_bridge(); } + + protected: + UARTMux *parent_; +}; + +template class IsLocalCondition final : public Condition { + public: + explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_local(); } + + protected: + UARTMux *parent_; +}; + +} // namespace esphome::uart_mux +#endif diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b805d5155a..8bbb9ed7f9 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -97,6 +97,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/tests/component_tests/uart_mux/__init__.py b/tests/component_tests/uart_mux/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/uart_mux/test_init.py b/tests/component_tests/uart_mux/test_init.py new file mode 100644 index 0000000000..2221ec5669 --- /dev/null +++ b/tests/component_tests/uart_mux/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the uart_mux component's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_BRIDGE_ID = "bridge_id" + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3} + ) + + +def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType: + return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)} + + +def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + uart_mux._final_validate(_mux_conf("mux_1", "bridge_1")) + + +def test_rejects_two_muxes_on_one_bridge( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"): + uart_mux._final_validate(_mux_conf("mux_1", "bridge_0")) diff --git a/tests/components/uart_mux/common.yaml b/tests/components/uart_mux/common.yaml new file mode 100644 index 0000000000..3477f78b70 --- /dev/null +++ b/tests/components/uart_mux/common.yaml @@ -0,0 +1,44 @@ +tinyusb: + id: tinyusb_test + on_mount: + - uart_mux.select_bridge: mux_0 + on_unmount: + - uart_mux.select_local: mux_0 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 + +bridge: + - platform: cdc_acm_uart + id: bridge_0 + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + +uart_mux: + - id: mux_0 + bridge_id: bridge_0 + initial_route: local + +interval: + - interval: 60s + then: + - if: + condition: + uart_mux.is_local: mux_0 + then: + - lambda: |- + uint8_t byte; + if (id(mux_0).available() && id(mux_0).read_byte(&byte)) { + id(mux_0).write_byte(byte); + } diff --git a/tests/components/uart_mux/test.esp32-p4-idf.yaml b/tests/components/uart_mux/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..ced6f1158e --- /dev/null +++ b/tests/components/uart_mux/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s2-idf.yaml b/tests/components/uart_mux/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..5eaa3b3847 --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s2-idf.yaml @@ -0,0 +1,7 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s3-idf.yaml b/tests/components/uart_mux/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..ced6f1158e --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml From a264093d86242919777fd869aa3fa794c3068edb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 21:57:06 -0500 Subject: [PATCH 073/316] [uart] Add IDFUARTComponent::flush_input() and use it in uart_mux (#19336) Co-authored-by: Claude Fable 5.1 --- esphome/components/uart/uart_component_esp_idf.h | 6 ++++++ esphome/components/uart_mux/uart_mux.cpp | 16 ++-------------- esphome/components/uart_mux/uart_mux.h | 1 - 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index d9297bfa34..3b8603f2ac 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -37,6 +37,12 @@ class IDFUARTComponent final : public UARTComponent, public Component { uint8_t get_hw_serial_number() { return this->uart_num_; } + /// Discard everything received so far: the peek cache and the driver's RX buffer. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + /** * Load the UART with the current settings. * @param dump_config (Optional, default `true`): True for displaying new settings or diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp index df2ee5ccf5..953e81d533 100644 --- a/esphome/components/uart_mux/uart_mux.cpp +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -2,8 +2,6 @@ #include "uart_mux.h" #include "esphome/core/log.h" -#include "driver/uart.h" - namespace esphome::uart_mux { static const char *const TAG = "uart_mux"; @@ -36,7 +34,7 @@ void UARTMux::loop() { return; } // Bytes that arrived during the hand-off belong to neither owner. - this->flush_input_(); + this->uart_->flush_input(); this->route_ = Route::ROUTE_LOCAL; ESP_LOGD(TAG, "UART routed to local consumers"); this->disable_loop(); @@ -97,7 +95,7 @@ void UARTMux::select_bridge() { // uart_read_bytes() on this port, and nothing local has run, so flush only a // completed hand-off. if (this->route_ == Route::ROUTE_LOCAL) { - this->flush_input_(); + this->uart_->flush_input(); } this->route_ = Route::ROUTE_BRIDGE; ESP_LOGD(TAG, "UART routed to bridge"); @@ -105,16 +103,6 @@ void UARTMux::select_bridge() { this->disable_loop(); } -void UARTMux::flush_input_() { - // Drain the UART component's one-byte peek cache first: the driver flush does not - // clear it, and draining afterwards could discard a freshly arrived byte instead. - uint8_t discard; - if (this->uart_->available() > 0) { - this->uart_->read_byte(&discard); - } - uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); -} - void UARTMux::write_array(const uint8_t *data, size_t len) { if (!this->is_local()) { ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 1eb2374714..8e32a16643 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -66,7 +66,6 @@ class UARTMux final : public uart::UARTComponent, public Component { }; void check_logger_conflict() override {} - void flush_input_(); // Publish settings_ through the UARTComponent getters. void apply_settings_(); From 160bc29c7c1ea27033ac99cdcf9bf9aa109516d1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:01:34 +1200 Subject: [PATCH 074/316] Bump version to 2026.9.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a5ed253819..a5d6da6333 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b5 +PROJECT_NUMBER = 2026.9.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8a9e969585..e6165bf16b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b5" +__version__ = "2026.9.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From b0996ac4045ee93ad5bd3284e35ec90652ca4cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:11:57 -0500 Subject: [PATCH 075/316] [template] Skip the switch optimistic and assumed state setters when they match the default (#19232) --- esphome/components/template/switch/__init__.py | 7 +++++-- .../template/switch/template_switch.h | 1 + .../template/config/switch_defaults.yaml | 18 ++++++++++++++++++ .../template/test_template_switch.py | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/template/config/switch_defaults.yaml create mode 100644 tests/component_tests/template/test_template_switch.py diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 37303abb0d..0b24686936 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -72,8 +72,11 @@ async def to_code(config): await automation.build_automation( var.get_turn_on_trigger(), [], config[CONF_TURN_ON_ACTION] ) - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) + # optimistic_ and assumed_state_ are false in C++; only emit setters to turn them on. + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + if config[CONF_ASSUMED_STATE]: + cg.add(var.set_assumed_state(True)) @automation.register_action( diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 1714b4f72b..3b8b6cde42 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -29,6 +29,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { void write_state(bool state) override; TemplateLambda f_; + // Codegen only emits these setters to turn them on bool optimistic_{false}; bool assumed_state_{false}; Trigger<> turn_on_trigger_; diff --git a/tests/component_tests/template/config/switch_defaults.yaml b/tests/component_tests/template/config/switch_defaults.yaml new file mode 100644 index 0000000000..4387fe07a5 --- /dev/null +++ b/tests/component_tests/template/config/switch_defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - platform: template + id: plain_switch + turn_on_action: + - logger.log: "on" + - platform: template + id: enabled_switch + optimistic: true + assumed_state: true diff --git a/tests/component_tests/template/test_template_switch.py b/tests/component_tests/template/test_template_switch.py new file mode 100644 index 0000000000..11c6a9cab8 --- /dev/null +++ b/tests/component_tests/template/test_template_switch.py @@ -0,0 +1,17 @@ +"""Tests for the template switch codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flags_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only true optimistic and assumed_state are set; false is the C++ initializer.""" + main_cpp = generate_main(component_config_path("switch_defaults.yaml")) + + assert "plain_switch->set_optimistic(" not in main_cpp + assert "plain_switch->set_assumed_state(" not in main_cpp + assert "enabled_switch->set_optimistic(true);" in main_cpp + assert "enabled_switch->set_assumed_state(true);" in main_cpp From 1eb445bacbc05c259bcead7a46b4b9c76b777218 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:12:48 -0500 Subject: [PATCH 076/316] [template] Move set_optimistic into the headers (#19278) --- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/lock/template_lock.cpp | 1 - esphome/components/template/lock/template_lock.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index d5e0967e1e..93ad887e58 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 20c092cda7..cca2104620 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -25,7 +25,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 6e73623ae9..4a293aab85 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -47,7 +47,6 @@ void TemplateLock::open_latch() { this->prev_trigger_ = &this->open_trigger_; this->open_trigger_.trigger(); } -void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } void TemplateLock::dump_config() { LOG_LOCK("", "Template Lock", this); diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 03e3e86d88..9b0a1ffe98 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -18,7 +18,7 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *get_lock_trigger() { return &this->lock_trigger_; } Trigger<> *get_unlock_trigger() { return &this->unlock_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 05288b2d4e..27134fc8b2 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -29,7 +29,6 @@ void TemplateSwitch::write_state(bool state) { if (this->optimistic_) this->publish_state(state); } -void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() { return &this->turn_on_trigger_; } diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 3b8b6cde42..9af8517f9a 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -17,7 +17,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void loop() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 3ebeec1285..c9aa161b11 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 76c4630aa0..e123f66d7e 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -23,7 +23,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_stop_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); From 70097ae02fca195ce31c8bd3ec6db78cd8c1b855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:06 -0500 Subject: [PATCH 077/316] [sds011] Move set_rx_mode_only into the header (#19298) --- esphome/components/sds011/sds011.cpp | 2 -- esphome/components/sds011/sds011.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 1c222e5e80..dfc7857266 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -106,8 +106,6 @@ void SDS011Component::loop() { } } -void SDS011Component::set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } - void SDS011Component::sds011_write_command_(const uint8_t *command_data) { this->write_byte(SDS011_MSG_HEAD); this->write_byte(SDS011_COMMAND_ID_REQUEST); diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 4f4571ab69..0a896cdc4c 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -12,7 +12,7 @@ class SDS011Component final : public Component, public uart::UARTDevice { SDS011Component() = default; /// Manually set the rx-only mode. Defaults to false. - void set_rx_mode_only(bool rx_mode_only); + void set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5_sensor) { pm_2_5_sensor_ = pm_2_5_sensor; } void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } From f95a876ef2874642daa3fc1adab30368a7c35b70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:20 -0500 Subject: [PATCH 078/316] [max44009] Move set_mode into the header (#19297) --- esphome/components/max44009/max44009.cpp | 2 -- esphome/components/max44009/max44009.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 6b8bdc8de5..731f584056 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -134,6 +134,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { } } -void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } - } // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index b62aed7a56..5eb1555350 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -16,7 +16,7 @@ class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, pub void setup() override; void dump_config() override; void update() override; - void set_mode(MAX44009Mode mode); + void set_mode(MAX44009Mode mode) { this->mode_ = mode; } bool set_continuous_mode(); bool set_low_power_mode(); From da3ddee767ff317f61d1dc73763cdef00ad3bced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:34 -0500 Subject: [PATCH 079/316] [st7789v] Move set_model_str into the header (#19296) --- esphome/components/st7789v/st7789v.cpp | 2 -- esphome/components/st7789v/st7789v.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index b3a60af8c3..2e07e24522 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -152,8 +152,6 @@ void ST7789V::update() { this->write_display_data(); } -void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } - void ST7789V::write_display_data() { uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 1b7ba318a6..4011e607c2 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -110,7 +110,7 @@ class ST7789V final : public display::DisplayBuffer, public spi::SPIDevice { public: - void set_model_str(const char *model_str); + void set_model_str(const char *model_str) { this->model_str_ = model_str; } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_backlight_pin(GPIOPin *backlight_pin) { this->backlight_pin_ = backlight_pin; } From a6fa67b7cac99e36e23d55a39eeed500dcb615ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:49 -0500 Subject: [PATCH 080/316] [bang_bang] Move the single store setters into the header (#19287) --- esphome/components/bang_bang/bang_bang_climate.cpp | 6 ------ esphome/components/bang_bang/bang_bang_climate.h | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 5dfb121342..a1104aa1b2 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -203,16 +203,10 @@ void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &awa this->away_config_ = away_config; } -void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - Trigger<> *BangBangClimate::get_idle_trigger() { return &this->idle_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() { return &this->cool_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() { return &this->heat_trigger_; } -void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void BangBangClimate::dump_config() { LOG_CLIMATE("", "Bang Bang Climate", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d83257f9f3..fff9bf873f 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -22,10 +22,10 @@ class BangBangClimate final : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); - void set_supports_cool(bool supports_cool); - void set_supports_heat(bool supports_heat); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config); From 2f76ac6362ae257ba1cc0c3c80828549bcd53f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:05 -0500 Subject: [PATCH 081/316] [thermostat] Move set_default_preset into the header (#19294) --- esphome/components/thermostat/thermostat_climate.cpp | 2 -- esphome/components/thermostat/thermostat_climate.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e830d359c6..f64673e13f 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1304,8 +1304,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { this->default_custom_preset_ = nullptr; } -void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4dc2a74d8e..b7d46eae22 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -92,7 +92,7 @@ class ThermostatClimate final : public climate::Climate, public Component { void loop() override; void set_default_preset(const char *custom_preset); - void set_default_preset(climate::ClimatePreset preset); + void set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { this->on_boot_restore_from_ = on_boot_restore_from; } From ee6e675d581846ce832b51064b13cf3b4550deb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:22 -0500 Subject: [PATCH 082/316] [esp32_camera] Move the single store setters into the header (#19285) --- .../components/esp32_camera/esp32_camera.cpp | 15 ---------- .../components/esp32_camera/esp32_camera.h | 30 +++++++++---------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 598fe61d46..03fdbc4de7 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -433,25 +433,10 @@ void ESP32Camera::set_pixel_format(ESP32CameraPixelFormat format) { } } void ESP32Camera::set_jpeg_quality(uint8_t quality) { this->config_.jpeg_quality = quality; } -void ESP32Camera::set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } -void ESP32Camera::set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } -void ESP32Camera::set_contrast(int contrast) { this->contrast_ = contrast; } -void ESP32Camera::set_brightness(int brightness) { this->brightness_ = brightness; } -void ESP32Camera::set_saturation(int saturation) { this->saturation_ = saturation; } -void ESP32Camera::set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* set exposure parameters */ -void ESP32Camera::set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } -void ESP32Camera::set_aec2(bool aec2) { this->aec2_ = aec2; } -void ESP32Camera::set_ae_level(int ae_level) { this->ae_level_ = ae_level; } -void ESP32Camera::set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* set gains parameters */ -void ESP32Camera::set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } -void ESP32Camera::set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } -void ESP32Camera::set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* set white balance */ -void ESP32Camera::set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* set test mode */ -void ESP32Camera::set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* set fps */ void ESP32Camera::set_max_update_interval(uint32_t max_update_interval) { this->max_update_interval_ = max_update_interval; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 83dab5f77a..9ff309ad4a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -140,25 +140,25 @@ class ESP32Camera final : public camera::Camera { void set_pixel_format(ESP32CameraPixelFormat format); void set_frame_size(ESP32CameraFrameSize size); void set_jpeg_quality(uint8_t quality); - void set_vertical_flip(bool vertical_flip); - void set_horizontal_mirror(bool horizontal_mirror); - void set_contrast(int contrast); - void set_brightness(int brightness); - void set_saturation(int saturation); - void set_special_effect(ESP32SpecialEffect effect); + void set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } + void set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } + void set_contrast(int contrast) { this->contrast_ = contrast; } + void set_brightness(int brightness) { this->brightness_ = brightness; } + void set_saturation(int saturation) { this->saturation_ = saturation; } + void set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* -- exposure */ - void set_aec_mode(ESP32GainControlMode mode); - void set_aec2(bool aec2); - void set_ae_level(int ae_level); - void set_aec_value(uint32_t aec_value); + void set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } + void set_aec2(bool aec2) { this->aec2_ = aec2; } + void set_ae_level(int ae_level) { this->ae_level_ = ae_level; } + void set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* -- gains */ - void set_agc_mode(ESP32GainControlMode mode); - void set_agc_value(uint8_t agc_value); - void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling); + void set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } + void set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } + void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* -- white balance */ - void set_wb_mode(ESP32WhiteBalanceMode mode); + void set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* -- test */ - void set_test_pattern(bool test_pattern); + void set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* -- framerates */ void set_max_update_interval(uint32_t max_update_interval); void set_idle_update_interval(uint32_t idle_update_interval); From cc9f4730b43460ad3782a1b1f7ff1add96992e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:39 -0500 Subject: [PATCH 083/316] [mqtt_subscribe] Move set_qos into the headers (#19282) --- .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp | 1 - .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h | 2 +- .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp | 1 - .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 40b5b46e1d..afb725feb6 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -25,7 +25,6 @@ void MQTTSubscribeSensor::setup() { } float MQTTSubscribeSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeSensor::dump_config() { LOG_SENSOR("", "MQTT Subscribe", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 739e8456ee..b0a8a0a78a 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -18,7 +18,7 @@ class MQTTSubscribeSensor final : public sensor::Sensor, public Component { void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index edc197671e..470e08d59a 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -15,7 +15,6 @@ void MQTTSubscribeTextSensor::setup() { this->qos_); } float MQTTSubscribeTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeTextSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "MQTT Subscribe Text Sensor", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 8641825fca..dc02eb5d18 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -17,7 +17,7 @@ class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Com void setup() override; void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; From 031b9804215f0fae19bbcfd9b6f98773f1ee9b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:15:07 -0500 Subject: [PATCH 084/316] [web_server] Move the CSS and JS setters into the header (#19286) --- esphome/components/web_server/web_server.cpp | 7 ------- esphome/components/web_server/web_server.h | 8 ++++---- esphome/components/web_server/web_server_v1.cpp | 4 ---- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e5..1683492da7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -336,13 +336,6 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} -#ifdef USE_WEBSERVER_CSS_INCLUDE -void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } -#endif -#ifdef USE_WEBSERVER_JS_INCLUDE -void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_include; } -#endif - json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0fbe4ec551..d60b39278a 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -204,14 +204,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_url The url to the web server stylesheet. */ - void set_css_url(const char *css_url); + void set_css_url(const char *css_url) { this->css_url_ = css_url; } /** Set the URL to the script that's embedded in the index page. Defaults to * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ - void set_js_url(const char *js_url); + void set_js_url(const char *js_url) { this->js_url_ = js_url; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE @@ -219,7 +219,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_include Local path to web server script. */ - void set_css_include(const char *css_include); + void set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE @@ -227,7 +227,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param js_include Local path to web server script. */ - void set_js_include(const char *js_include); + void set_js_include(const char *js_include) { this->js_include_ = js_include; } #endif /** Determine whether internal components should be displayed on the web server. diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 85a4e80541..08654e353a 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -69,10 +69,6 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print(""); } -void WebServer::set_css_url(const char *css_url) { this->css_url_ = css_url; } - -void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } - void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); const auto &title = App.get_name(); From 456119ec38e1162bb01292c6e8d8c7e08b7fe67c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:05 -0500 Subject: [PATCH 085/316] [graphical_display_menu] Move set_display and set_font into the header (#19291) --- .../graphical_display_menu/graphical_display_menu.cpp | 4 ---- .../graphical_display_menu/graphical_display_menu.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index f0642d2e8c..d261c48855 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -57,10 +57,6 @@ void GraphicalDisplayMenu::dump_config() { } } -void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } - -void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } - void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ccdf3d304c..13c0f9d73f 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -38,8 +38,8 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen void setup() override; void dump_config() override; - void set_display(display::Display *display); - void set_font(display::BaseFont *font); + void set_display(display::Display *display) { this->display_ = display; } + void set_font(display::BaseFont *font) { this->font_ = font; } template void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } void set_foreground_color(Color foreground_color); void set_background_color(Color background_color); From 7e3a1cf272470b19ab3be0a87f6e3d665118f028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:10 -0500 Subject: [PATCH 086/316] [tsl2561] Move set_is_cs_package into the header (#19289) --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 963114b230..5c53ed607f 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -146,7 +146,6 @@ void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time this->integration_time_ = integration_time; } void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } -void TSL2561Sensor::set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8997d19f53..8f6251c134 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -59,7 +59,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param package_cs Is this a CS package. */ - void set_is_cs_package(bool package_cs); + void set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) From 534b4a0f44fa7434abcf3224f13b84783ab2b811 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:18 -0500 Subject: [PATCH 087/316] [template] Move the cover and valve set_has_* setters into the headers (#19283) --- esphome/components/template/cover/template_cover.cpp | 4 ---- esphome/components/template/cover/template_cover.h | 8 ++++---- esphome/components/template/valve/template_valve.cpp | 4 ---- esphome/components/template/valve/template_valve.h | 6 +++--- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 93ad887e58..1efab9c5b8 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -116,10 +116,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; } -void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } -void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index cca2104620..e69c91bf09 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -27,10 +27,10 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_tilt(bool has_tilt); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index c9aa161b11..f35fdbeaf1 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -110,10 +110,6 @@ ValveTraits TemplateValve::get_traits() { Trigger *TemplateValve::get_position_trigger() { return &this->position_trigger_; } -void TemplateValve::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateValve::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateValve::set_has_position(bool has_position) { this->has_position_ = has_position; } - void TemplateValve::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index e123f66d7e..9c39a3624c 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -25,9 +25,9 @@ class TemplateValve final : public valve::Valve, public Component { Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateValveRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; From 1e8f5fa481d3f375d42a980bf9e3f1fd24b95716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:24 -0500 Subject: [PATCH 088/316] [haier] Move set_send_wifi into the header (#19299) --- esphome/components/haier/haier_base.cpp | 2 -- esphome/components/haier/haier_base.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 48f72dc16b..87f9331d55 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -190,8 +190,6 @@ void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); } -void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } - void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index db4c1abceb..18ddbcc1cc 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -71,7 +71,7 @@ class HaierClimateBase : public esphome::Component, }; bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); - void set_send_wifi(bool send_wifi); + void set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } void send_custom_command(const haier_protocol::HaierMessage &message); template void add_status_message_callback(F &&callback) { this->status_message_callback_.add(std::forward(callback)); From d186b4ba9f8dfb6b78b0a77c67467d980f1b7342 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:42 -0500 Subject: [PATCH 089/316] [openthread] Move set_mdns into the header (#19295) --- esphome/components/openthread/openthread.cpp | 2 -- esphome/components/openthread/openthread.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index b98f109172..ae896fcfee 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,8 +227,6 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } -void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } - bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f4c6d0962a..b83ffdb6af 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -90,7 +90,7 @@ extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguide class OpenThreadSrpComponent final : public Component { public: - void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; From 37a08e6cb6f1ef57e1f6069e59ba09cd2a958ce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:47 -0500 Subject: [PATCH 090/316] [lc709203f] Move the single store setters into the header (#19290) --- esphome/components/lc709203f/lc709203f.cpp | 4 ---- esphome/components/lc709203f/lc709203f.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a5dda6ca43..36e5bce8e3 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -275,8 +275,4 @@ void Lc709203f::set_pack_size(uint16_t pack_size) { // not cause an error or crash, so I am not doing any additional checking here. } -void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } - -void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } - } // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 46f773873a..e9c60e285f 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -26,8 +26,8 @@ class Lc709203f final : public sensor::Sensor, public PollingComponent, public i void dump_config() override; void set_pack_size(uint16_t pack_size); - void set_thermistor_b_constant(uint16_t b_constant); - void set_pack_voltage(LC709203FBatteryVoltage pack_voltage); + void set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } + void set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) { battery_remaining_sensor_ = battery_remaining_sensor; From fdb86cbd2ea6db0501c5ce8c04d71aed9cd2d8b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:53 -0500 Subject: [PATCH 091/316] [tsl2591] Move the single store setters into the header (#19288) --- esphome/components/tsl2591/tsl2591.cpp | 6 ------ esphome/components/tsl2591/tsl2591.h | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 2a5d6a4ee4..a741b08797 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -200,8 +200,6 @@ void TSL2591Component::set_infrared_sensor(sensor::Sensor *infrared_sensor) { this->infrared_sensor_ = infrared_sensor; } -void TSL2591Component::set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } - void TSL2591Component::set_full_spectrum_sensor(sensor::Sensor *full_spectrum_sensor) { this->full_spectrum_sensor_ = full_spectrum_sensor; } @@ -242,10 +240,6 @@ void TSL2591Component::set_integration_time_and_gain(TSL2591IntegrationTime inte } } -void TSL2591Component::set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } - -void TSL2591Component::set_name(const char *name) { this->name_ = name; } - bool TSL2591Component::is_adc_valid() { uint8_t status; if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_STATUS, &status)) { diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 3fde340412..1e699329eb 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -111,13 +111,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { * * @param enable Enable or disable power save mode. */ - void set_power_save_mode(bool enable); + void set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } /** Sets the name for this instance of the device. * * @param name The user-friendly name. */ - void set_name(const char *name); + void set_name(const char *name) { this->name_ = name; } /** Sets the device and glass attenuation factors. * @@ -235,7 +235,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. */ void set_infrared_sensor(sensor::Sensor *infrared_sensor); /** Used by ESPHome framework. */ - void set_visible_sensor(sensor::Sensor *visible_sensor); + void set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } /** Used by ESPHome framework. */ void set_calculated_lux_sensor(sensor::Sensor *calculated_lux_sensor); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ From 02435e7455eef3e3866d890d6bf0b308b30f1289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:17:12 -0500 Subject: [PATCH 092/316] [waveshare_epaper] Move set_full_update_every into the header (#19280) --- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 4 ---- esphome/components/waveshare_epaper/waveshare_epaper.h | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 14ff5ed53c..93f23424c0 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -2183,8 +2183,6 @@ void GDEW029T5::write_lut_(const uint8_t *lut, const uint8_t size) { this->end_data_(); } -void GDEW029T5::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } - int GDEW029T5::get_width_internal() { return 128; } int GDEW029T5::get_height_internal() { return 296; } void GDEW029T5::dump_config() { @@ -2523,7 +2521,6 @@ void HOT GDEY042T81::display() { ESP_LOGD(TAG, "Set the display back to deep sleep"); this->deep_sleep(); } -void GDEY042T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY042T81::get_width_internal() { return 400; } int GDEY042T81::get_height_internal() { return 300; } uint32_t GDEY042T81::idle_timeout_() { return 5000; } @@ -3156,7 +3153,6 @@ void HOT GDEY0583T81::display() { this->deep_sleep(); } -void GDEY0583T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY0583T81::get_width_internal() { return 648; } int GDEY0583T81::get_height_internal() { return 480; } uint32_t GDEY0583T81::idle_timeout_() { return 5000; } diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index fa3737238e..7e16ce3dc3 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -272,7 +272,7 @@ class GDEW029T5 : public WaveshareEPaper { void dump_config() override; void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: void init_display_(); @@ -503,7 +503,7 @@ class GDEY042T81 : public WaveshareEPaper { this->data(0x01); } - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: uint32_t full_update_every_{30}; @@ -695,7 +695,7 @@ class GDEY0583T81 : public WaveshareEPaper { void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: int get_width_internal() override; From 7ed4950c695fa680f0473e55027ee3265bfc1441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:22 -0500 Subject: [PATCH 093/316] [bme280_base][bme680][bmp280_base] Move set_iir_filter into the headers (#19281) --- esphome/components/bme280_base/bme280_base.cpp | 1 - esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme680/bme680.cpp | 1 - esphome/components/bme680/bme680.h | 2 +- esphome/components/bmp280_base/bmp280_base.cpp | 1 - esphome/components/bmp280_base/bmp280_base.h | 2 +- 6 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 0f7e42cce3..11c796352a 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -341,7 +341,6 @@ void BME280Component::set_pressure_oversampling(BME280Oversampling pressure_over void BME280Component::set_humidity_oversampling(BME280Oversampling humidity_over_sampling) { this->humidity_oversampling_ = humidity_over_sampling; } -void BME280Component::set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BME280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->read_byte(a_register, &data); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 7fe5f7401d..8b4906b7b7 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -69,7 +69,7 @@ class BME280Component : public PollingComponent { /// Set the oversampling value for the humidity sensor. Default is 16x. void set_humidity_oversampling(BME280Oversampling humidity_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BME280IIRFilter iir_filter); + void set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 164424de09..bac8ed8a5a 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -503,7 +503,6 @@ void BME680Component::set_pressure_oversampling(BME680Oversampling pressure_over void BME680Component::set_humidity_oversampling(BME680Oversampling humidity_oversampling) { this->humidity_oversampling_ = humidity_oversampling; } -void BME680Component::set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_duration) { this->heater_temperature_ = heater_temperature; this->heater_duration_ = heater_duration; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index a274578fc1..e401d03659 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -74,7 +74,7 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the humidity oversampling value. Defaults to 16X. void set_humidity_oversampling(BME680Oversampling humidity_oversampling); /// Set the IIR Filter value. Defaults to no IIR Filter. - void set_iir_filter(BME680IIRFilter iir_filter); + void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 1dae5a689e..34e1d67101 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -254,7 +254,6 @@ void BMP280Component::set_temperature_oversampling(BMP280Oversampling temperatur void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over_sampling) { this->pressure_oversampling_ = pressure_over_sampling; } -void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->bmp_read_byte(a_register, &data); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 3bf1edab04..860fff6b4b 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -59,7 +59,7 @@ class BMP280Component : public PollingComponent { /// Set the oversampling value for the pressure sensor. Default is 16x. void set_pressure_oversampling(BMP280Oversampling pressure_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BMP280IIRFilter iir_filter); + void set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void setup() override; void dump_config() override; From 4fba837d408e03dbab50849c441112a12f2d9d4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:32 -0500 Subject: [PATCH 094/316] [adc] Move set_sampling_mode into the header (#19293) --- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc/adc_sensor_common.cpp | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 7131898747..46b7e7a2ff 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -94,7 +94,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v /// - SamplingMode::MIN: Use the lowest sample value /// - SamplingMode::MAX: Use the highest sample value /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. - void set_sampling_mode(SamplingMode sampling_mode); + void set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } /// Perform a single ADC sampling operation and return the measured value. /// This function handles raw readings, calibration, and averaging as needed. diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 5ca58df10e..70211000c3 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -76,6 +76,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { } } -void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } - } // namespace esphome::adc From d61e0e46952c6fc77286bad0dd19f4d4d0e60340 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:26:51 -0500 Subject: [PATCH 095/316] [tsl2561][tsl2591] Move set_gain into the headers (#19284) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.cpp | 2 -- esphome/components/tsl2591/tsl2591.h | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 5c53ed607f..4e4d403488 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -145,7 +145,6 @@ float TSL2561Sensor::get_integration_time_ms_() { void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time) { this->integration_time_ = integration_time; } -void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8f6251c134..0800b87c46 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -51,7 +51,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param gain The new gain. */ - void set_gain(TSL2561Gain gain); + void set_gain(TSL2561Gain gain) { this->gain_ = gain; } /** The "CS" package of this sensor has a slightly different formula for * converting the raw values. Use this setting to indicate that this is a CS diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index a741b08797..d147aae88a 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -216,8 +216,6 @@ void TSL2591Component::set_integration_time(TSL2591IntegrationTime integration_t this->integration_time_ = integration_time; } -void TSL2591Component::set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } - void TSL2591Component::set_device_and_glass_attenuation_factors(float device_factor, float glass_attenuation_factor) { this->device_factor_ = device_factor; this->glass_attenuation_factor_ = glass_attenuation_factor; diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 1e699329eb..c65fc5f6e5 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -241,7 +241,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. Does NOT actually set the value on the device. */ void set_integration_time(TSL2591IntegrationTime integration_time); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ - void set_gain(TSL2591ComponentGain gain); + void set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } /** Used by ESPHome framework. */ void setup() override; /** Used by ESPHome framework. */ From f0315ea1ecaafce83c1439f29a787f0680328e9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:38:36 -0500 Subject: [PATCH 096/316] [template][modbus_controller] Move set_assumed_state into the headers (#19279) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/modbus_controller/switch/modbus_switch.cpp | 2 -- esphome/components/modbus_controller/switch/modbus_switch.h | 2 +- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 7bf45366c0..f2aae201f3 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -25,8 +25,6 @@ void ModbusSwitch::setup() { } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } -void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(std::span data) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 688a620bac..b98543532e 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -31,7 +31,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void setup() override; void write_state(bool state) override; void dump_config() override; - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->set_controller_(parent); } diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 1efab9c5b8..1bf057da5b 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index e69c91bf09..d3096ba86f 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -26,7 +26,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 27134fc8b2..edd753d3d2 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -53,6 +53,5 @@ void TemplateSwitch::dump_config() { LOG_SWITCH("", "Template Switch", this); ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } } // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 9af8517f9a..6dc073e4b3 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -18,7 +18,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index f35fdbeaf1..5090687639 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 9c39a3624c..504fdb2fba 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -24,7 +24,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } From 2ffa57c907585593bc26a8a95b487cc3ab9c2985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:07:27 -0500 Subject: [PATCH 097/316] [esp32_ble_tracker] Initialize ESPBTClient::app_id (#19199) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 1a424a4a8e..aa470983df 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -135,7 +135,7 @@ class ESPBTClient : public ESPBTDeviceListener { void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout - uint8_t app_id; // App IDs are small integers assigned sequentially + uint8_t app_id{0}; // App IDs are small integers assigned sequentially protected: /// Set state without IDLE handling - use for direct state transitions. From f30a3bc3e13bf96b3de41f72d67a1dab2b7256d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:21 -0500 Subject: [PATCH 098/316] [bluetooth_connection] Use a user provided default constructor for BluetoothConnection (#19200) --- .../components/bluetooth_connection/bluetooth_connection_hub.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 47181e81a7..4c87b876c3 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -37,6 +37,9 @@ enum class PendingAck : uint8_t { class BluetoothConnection final : public ble_device_base::GattClientListener { public: + // User provided, not "= default": `new(p) BluetoothConnection()` would zero-fill .bss that is already zero. + BluetoothConnection() {} + /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; From ad20711a22d8c36e12d165549330825281777ae1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:24 -0500 Subject: [PATCH 099/316] [restart] Use a user provided default constructor for RestartSwitch (#19204) --- esphome/components/restart/switch/restart_switch.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index dc9ec8eadc..03cf03f166 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) RestartSwitch()` would zero-fill .bss that is already zero. + RestartSwitch() {} + void dump_config() override; protected: From f686fb606beed1dd3233b6eeb57cfede0eb8e471 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:30 -0500 Subject: [PATCH 100/316] [gpio] Use a user provided default constructor for GPIOSwitch (#19203) --- esphome/components/gpio/switch/gpio_switch.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 7ed0de7c6f..e7323e6e93 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -9,6 +9,9 @@ namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) GPIOSwitch()` would zero-fill .bss that is already zero. + GPIOSwitch() {} + void set_pin(GPIOPin *pin) { pin_ = pin; } // ========== INTERNAL METHODS ========== @@ -25,7 +28,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK FixedVector interlock_; uint32_t interlock_wait_time_{0}; From 5198ef8dd8a6b53ba91f5a00bf6c4a6ab25ab974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:43 -0500 Subject: [PATCH 101/316] [binary_sensor] Use a user provided default constructor for DelayedOnFilter (#19205) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 6887de35e1..bb974fe132 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -53,6 +53,9 @@ class DelayedOnOffFilter final : public Filter { class DelayedOnFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero. + DelayedOnFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 11ce3594c0725fc3eb24776ed70f5336f552e103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:49 -0500 Subject: [PATCH 102/316] [binary_sensor] Use a user provided default constructor for DelayedOffFilter (#19206) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index bb974fe132..83f4a9b772 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -66,6 +66,9 @@ class DelayedOnFilter : public Filter { class DelayedOffFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOffFilter()` would zero-fill .bss that is already zero. + DelayedOffFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 274bfe7aeeabd1976867d6cc972a176f4a871807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:15:51 -0500 Subject: [PATCH 103/316] [binary_sensor] Use a user provided default constructor (#19111) --- esphome/components/binary_sensor/binary_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 28c156763a..a96113b520 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor() = default; + // User provided, not "= default": `new(p) BinarySensor()` would zero-fill .bss that is already zero. + explicit BinarySensor() {} const bool &get_state() const override { return this->state; } void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } From 42aea9efbc0516a028fe9f101a24218e1fb1c692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:44 -0500 Subject: [PATCH 104/316] [ld2450] Use a user provided default constructor for RestartButton (#19183) --- esphome/components/ld2450/button/restart_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index 9219011f8b..87b1a2bbd1 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: - RestartButton() = default; + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} protected: void press_action() override; From 644f0ededbf657b99d839ecb46460d589cb6446c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:47 -0500 Subject: [PATCH 105/316] [ld2450] Use a user provided default constructor for FactoryResetButton (#19182) --- esphome/components/ld2450/button/factory_reset_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 392fc67ffd..71dc19a6cd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: - FactoryResetButton() = default; + // User provided, not "= default": `new(p) FactoryResetButton()` would zero-fill .bss that is already zero. + FactoryResetButton() {} protected: void press_action() override; From d16a1ec288ed53ef296a1f122a92ecbc8fe17224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:53 -0500 Subject: [PATCH 106/316] [ld2412] Use a user provided default constructor for DistanceResolutionSelect (#19185) --- esphome/components/ld2412/select/distance_resolution_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index be8dba90b5..d1bc15dea9 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class DistanceResolutionSelect final : public select::Select, public Parented { public: - DistanceResolutionSelect() = default; + // User provided, not "= default": `new(p) DistanceResolutionSelect()` would zero-fill .bss that is already zero. + DistanceResolutionSelect() {} protected: void control(size_t index) override; From 711d016a5099071884a150069cdb46718ba867ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:58 -0500 Subject: [PATCH 107/316] [ld2412] Use a user provided default constructor for BaudRateSelect (#19184) --- esphome/components/ld2412/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 46ec9be1d1..527b1a1e93 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BaudRateSelect final : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From e77a8525993cfdaaee6d935ecf5ae2c9a9517e70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:03 -0500 Subject: [PATCH 108/316] [ld2412] Use a user provided default constructor for LightOutControlSelect (#19186) --- esphome/components/ld2412/select/light_out_control_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index c8988fda78..0867f3b1c2 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightOutControlSelect final : public select::Select, public Parented { public: - LightOutControlSelect() = default; + // User provided, not "= default": `new(p) LightOutControlSelect()` would zero-fill .bss that is already zero. + LightOutControlSelect() {} protected: void control(size_t index) override; From 7d1c9d648830cfe1eca3be1f7567cb22443a58a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:08 -0500 Subject: [PATCH 109/316] [ld2450] Use a user provided default constructor for BaudRateSelect (#19187) --- esphome/components/ld2450/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index cb53118170..af4c477dff 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From 4d648bf872ece7a17a778126f002a98f3e5ef886 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:13 -0500 Subject: [PATCH 110/316] [ld2450] Use a user provided default constructor for BluetoothSwitch (#19191) --- esphome/components/ld2450/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3d48a89b57..8b118a7b8c 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 794aefb2761265c177ed949095819991282c1814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:17 -0500 Subject: [PATCH 111/316] [ld2412] Use a user provided default constructor for BluetoothSwitch (#19189) --- esphome/components/ld2412/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 8fd4a86e43..e753613cdf 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BluetoothSwitch final : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 313d0c1eb3f35f515d326f8ceaec788241fd7c66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:22 -0500 Subject: [PATCH 112/316] [number] Initialize Number::state (#19114) --- esphome/components/number/number.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 579d488cf0..b697e770be 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -28,7 +28,7 @@ class Number; */ class Number : public EntityBase { public: - float state; + float state{}; void publish_state(float state); From bd47f04479c692b368a6af8799c1725ae2e88008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:51 -0500 Subject: [PATCH 113/316] [ld2450] Use a user provided default constructor for PresenceTimeoutNumber (#19195) --- esphome/components/ld2450/number/presence_timeout_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index 09c8afca55..8c44fa39dc 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: - PresenceTimeoutNumber() = default; + // User provided, not "= default": `new(p) PresenceTimeoutNumber()` would zero-fill .bss that is already zero. + PresenceTimeoutNumber() {} protected: void control(float value) override; From b0f87615a23756c7cb22bd5e50859acee87c87f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:54 -0500 Subject: [PATCH 114/316] [ld2450] Use a user provided default constructor for ZoneTypeSelect (#19188) --- esphome/components/ld2450/select/zone_type_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 566346eb48..cf79c2324d 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: - ZoneTypeSelect() = default; + // User provided, not "= default": `new(p) ZoneTypeSelect()` would zero-fill .bss that is already zero. + ZoneTypeSelect() {} protected: void control(size_t index) override; From 43e35c4fc26259d984f3f57440d24df8b9f233cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:20:10 -0500 Subject: [PATCH 115/316] [ld2412] Use a user provided default constructor for EngineeringModeSwitch (#19190) --- esphome/components/ld2412/switch/engineering_mode_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index defeb4c76b..279128ddbc 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: - EngineeringModeSwitch() = default; + // User provided, not "= default": `new(p) EngineeringModeSwitch()` would zero-fill .bss that is already zero. + EngineeringModeSwitch() {} protected: void write_state(bool state) override; From 2fde80454f170f8aa3389ed8702c3a43c805e3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:30 -0500 Subject: [PATCH 116/316] [ld2412] Use a user provided default constructor for LightThresholdNumber (#19193) --- esphome/components/ld2412/number/light_threshold_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index f62d523af3..710b47957c 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightThresholdNumber final : public number::Number, public Parented { public: - LightThresholdNumber() = default; + // User provided, not "= default": `new(p) LightThresholdNumber()` would zero-fill .bss that is already zero. + LightThresholdNumber() {} protected: void control(float value) override; From 511095d3da567445695364db4305dc53ef5667cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:58 -0500 Subject: [PATCH 117/316] [scd4x] Use a user provided default constructor for PerformForcedCalibrationAction (#19176) --- esphome/components/scd4x/automation.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index 4746c0c879..e0cc04e2cb 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -9,6 +9,10 @@ namespace esphome::scd4x { template class PerformForcedCalibrationAction final : public Action, public Parented { public: + // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already + // zero. + PerformForcedCalibrationAction() {} + void play(const Ts &...x) override { if (this->value_.has_value()) { this->parent_->perform_forced_calibration(this->value_.value(x...)); From e50428fce596f0f671123060cd85cd9ec075c68b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:22:34 -0500 Subject: [PATCH 118/316] [safe_mode] Use a user provided default constructor for SafeModeComponent (#19163) --- esphome/components/safe_mode/safe_mode.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 0633c92a78..903d9eb79f 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,6 +17,9 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: + // User provided, not "= default": `new(p) SafeModeComponent()` would zero-fill .bss that is already zero. + SafeModeComponent() {} + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode From 9162dc38cee7d4a0ed08f45d4d2a1bc30278ddcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:23:50 -0500 Subject: [PATCH 119/316] [bluetooth_connection] Use a user provided default constructor for BluedroidGattClient (#19201) --- .../bluetooth_connection/bluetooth_connection_bluedroid.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index f285260e76..a4e9edec23 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -31,6 +31,9 @@ class BluetoothConnection; // void disconnect() cannot overload with an int-returning twin. class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { public: + // User provided, not "= default": `new(p) BluedroidGattClient()` would zero-fill .bss that is already zero. + BluedroidGattClient() {} + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; // Lifecycle of one connection attempt's service search. From 9a76f1be4fc435adf670f04e51a682891988d01f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:21 -0500 Subject: [PATCH 120/316] [core] Give StaticVector a user provided default constructor (#19110) --- esphome/core/helpers.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b1f24b25a3..cfc92932a9 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -242,8 +242,9 @@ template class StaticVector { size_t count_{0}; public: - // Default constructor - StaticVector() = default; + // User provided, not "= default": otherwise `StaticVector<...> x_{}` members + // value-initialize and memset data_, defeating the comment above. + constexpr StaticVector() noexcept {} // Iterator range constructor template StaticVector(InputIt first, InputIt last) { From 0c1cf6d0a9e6c07e431fcdd1ae13a4c5c5ae29c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:27 -0500 Subject: [PATCH 121/316] [template] Use a user provided default constructor for TemplateBinarySensor (#19128) --- .../components/template/binary_sensor/template_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index c78a95e0e3..e1a089b44c 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: + // User provided, not "= default": `new(p) TemplateBinarySensor()` would zero-fill .bss that is already zero. + TemplateBinarySensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; From 8ffa10f86ced8d6e8d08e8be591e6a594d39a282 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:33 -0500 Subject: [PATCH 122/316] [version] Use a user provided default constructor for VersionTextSensor (#19139) --- esphome/components/version/version_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 96f72ad035..7ff6ac4d35 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,6 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: + // User provided, not "= default": `new(p) VersionTextSensor()` would zero-fill .bss that is already zero. + VersionTextSensor() {} void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; From 7f61909415fc5e921ecaced7069d4204f339dc9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:42 -0500 Subject: [PATCH 123/316] [binary_sensor] Use a user provided default constructor for SettleFilter (#19135) --- esphome/components/binary_sensor/filter.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 83f4a9b772..1ec255d63d 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -149,6 +149,8 @@ class StatelessLambdaFilter : public Filter { class SettleFilter : public Filter { public: + // User provided, not "= default": `new(p) SettleFilter()` would zero-fill .bss that is already zero. + SettleFilter() {} optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 14e640c90390588fe5f4f6e2535a7cd37aa9845e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:48 -0500 Subject: [PATCH 124/316] [internal_temperature] Use a user provided default constructor for InternalTemperatureSensor (#19141) --- esphome/components/internal_temperature/internal_temperature.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 90831cf211..6a9889ef29 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -13,6 +13,9 @@ namespace esphome::internal_temperature { class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) InternalTemperatureSensor()` would zero-fill .bss that is already zero. + InternalTemperatureSensor() {} + #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; #endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) From ba50e23b81666912688a970036e84d1a969aad44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:58 -0500 Subject: [PATCH 125/316] [uptime] Use a user provided default constructor for UptimeSecondsSensor (#19140) --- esphome/components/uptime/sensor/uptime_seconds_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index b0b12954b2..92d475e62e 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -7,6 +7,8 @@ namespace esphome::uptime { class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) UptimeSecondsSensor()` would zero-fill .bss that is already zero. + UptimeSecondsSensor() {} void update() override; void dump_config() override; From 25a3f7068e09f9b065da3ad6a63f0797f4825e46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:01 -0500 Subject: [PATCH 126/316] [status] Use a user provided default constructor for StatusBinarySensor (#19138) --- esphome/components/status/status_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 28cf4cd083..3c25a9e57d 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -7,6 +7,8 @@ namespace esphome::status { class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: + // User provided, not "= default": `new(p) StatusBinarySensor()` would zero-fill .bss that is already zero. + StatusBinarySensor() {} void update() override; void setup() override; From df4176f9e1f1551b01a75ad6689c17042142af61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:08 -0500 Subject: [PATCH 127/316] [uart] Use a user provided default constructor for IDFUARTComponent (#19142) --- esphome/components/uart/uart_component_esp_idf.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b8603f2ac..b591fbe968 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -18,6 +18,8 @@ namespace esphome::uart { /// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent final : public UARTComponent, public Component { public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -102,7 +104,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { Framing last_good_framing_{}; bool has_peek_{false}; - uint8_t peek_byte_; + uint8_t peek_byte_{0}; uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX From 4e0245da0db6d70e281033d08a922b6c4e5f31b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:13 -0500 Subject: [PATCH 128/316] [gpio] Use a user provided default constructor for GPIOBinarySensor (#19143) --- esphome/components/gpio/binary_sensor/gpio_binary_sensor.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 956443fab5..80636e29a6 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -47,6 +47,9 @@ class GPIOBinarySensorStore { class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: + // User provided, not "= default": `new(p) GPIOBinarySensor()` would zero-fill .bss that is already zero. + GPIOBinarySensor() {} + // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. @@ -70,7 +73,7 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon void loop() override; protected: - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; GPIOBinarySensorStore store_; }; From 5b53c63eab74681c65575672754a9650f27fe085 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:18 -0500 Subject: [PATCH 129/316] [http_request] Use a user provided default constructor for HttpRequestIDF (#19175) --- esphome/components/http_request/http_request_idf.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 16a5b6a161..1c062af81b 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -30,6 +30,9 @@ class HttpContainerIDF : public HttpContainer { class HttpRequestIDF final : public HttpRequestComponent { public: + // User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero. + HttpRequestIDF() {} + void dump_config() override; void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } From ba679d91e413fafb30e518d164938798284fa9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:42 -0500 Subject: [PATCH 130/316] [restart] Use a user provided default constructor for RestartButton (#19168) --- esphome/components/restart/button/restart_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 974db0cec4..4baac6472c 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} + void dump_config() override; protected: From 7eebe18e1a772f75ca2556be3cd655acdbd10be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:48 -0500 Subject: [PATCH 131/316] [ethernet_info] Use a user provided default constructor for IPAddressEthernetInfo (#19166) --- esphome/components/ethernet_info/ethernet_info_text_sensor.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 11002d51ba..c9fcda225f 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -13,6 +13,9 @@ class IPAddressEthernetInfo final : public Component, public text_sensor::TextSensor, public ethernet::EthernetIPStateListener { public: + // User provided, not "= default": `new(p) IPAddressEthernetInfo()` would zero-fill .bss that is already zero. + IPAddressEthernetInfo() {} + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } From ac3ef0a02dc09a24315899a0e993eee4c3cde7f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:00 -0500 Subject: [PATCH 132/316] [preferences] Use a user provided default constructor for IntervalSyncer (#19164) --- esphome/components/preferences/syncer.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8a809672db..5092c32147 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -9,6 +9,9 @@ namespace esphome::preferences { class IntervalSyncer final : public PollingComponent { public: + // User provided, not "= default": `new(p) IntervalSyncer()` would zero-fill .bss that is already zero. + IntervalSyncer() {} + // Remove before 2027.3.0 ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } From 569001d554bdb2d80ae5298b2dadb8982ad65efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:07 -0500 Subject: [PATCH 133/316] [template] Use a user provided default constructor for TemplateSensor (#19125) --- esphome/components/template/sensor/template_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 825a2b4ffa..68e2237267 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateSensor()` would zero-fill .bss that is already zero. + TemplateSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From 4714b77f45b8de4c66c224d67e4230cc56fa550c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:28 -0500 Subject: [PATCH 134/316] [core] Construct App without value initialization (#19109) --- esphome/core/application.h | 2 +- esphome/core/config.py | 5 +++-- tests/unit_tests/core/test_config.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac8..f1cf6fcca0 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -528,7 +528,7 @@ class Application { // 1-byte members (grouped together to minimize padding) uint8_t app_state_{0}; - bool name_add_mac_suffix_; + bool name_add_mac_suffix_{false}; bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 67a7b5210e..8a4eb0fc37 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -717,9 +717,10 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) - # Construct App via placement new — see application.cpp for storage details + # Construct App via placement new — see application.cpp for storage details. + # No parens: `Application()` would zero-fill storage that is already zero. cg.add_global(cg.RawStatement("#include ")) - cg.add(cg.RawExpression("new (&App) Application()")) + cg.add(cg.RawExpression("new (&App) Application")) name = config[CONF_NAME] friendly_name = config[CONF_FRIENDLY_NAME] name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 8ab3ad5d15..07cff003cd 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -175,6 +175,27 @@ async def test_core_area_recorded_at_config_load( assert CORE.area == expected_area +@pytest.mark.asyncio +async def test_app_is_default_initialized( + yaml_file: Callable[[str], Path], +) -> None: + """App is constructed with `new (&App) Application`, no parentheses. + + `Application()` would value-initialize and memset the whole object into + storage that is already zero.""" + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + raw_expressions = [c.args[0] for c in mock_cg.RawExpression.call_args_list] + assert "new (&App) Application" in raw_expressions + assert "new (&App) Application()" not in raw_expressions + + def test_config_load_without_area_clears_stale_core_area( yaml_file: Callable[[str], Path], ) -> None: From b7064f2bedc3800393605f1f4937b5af017863f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:31 -0500 Subject: [PATCH 135/316] [update] Initialize UpdateInfo::progress (#19121) --- esphome/components/update/update_entity.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f925d338ff..96ba6dbd56 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -16,7 +16,7 @@ struct UpdateInfo { std::string firmware_url; std::string md5; bool has_progress{false}; - float progress; + float progress{0}; }; enum UpdateState : uint8_t { From da73140c9ca1336a4a9c9fcf56b702aa52e8df56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:36 -0500 Subject: [PATCH 136/316] [template] Use a user provided default constructor for TemplateTextSensor (#19126) --- esphome/components/template/text_sensor/template_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 0538a7ec21..8f03f78be4 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -9,6 +9,8 @@ namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateTextSensor()` would zero-fill .bss that is already zero. + TemplateTextSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From cbb66002635d3fbc5fa549b67e76382838f8ceac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:41 -0500 Subject: [PATCH 137/316] [template] Use a user provided default constructor for TemplateButton (#19124) --- esphome/components/template/button/template_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index f64a85eef0..bd07b2258c 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -6,6 +6,9 @@ namespace esphome::template_ { class TemplateButton final : public button::Button { public: + // User provided, not "= default": `new(p) TemplateButton()` would zero-fill .bss that is already zero. + TemplateButton() {} + // Implements the abstract `press_action` but the `on_press` trigger already handles the press. void press_action() override{}; }; From a495fe56f5f3f00d6eb2d8430901115c701811f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:48 -0500 Subject: [PATCH 138/316] [template] Use a user provided default constructor for TemplateSelect (#19130) --- esphome/components/template/select/template_select.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 5da6d732bd..1cc28a36d3 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -23,6 +23,8 @@ void update_lambda(BaseTemplateSelect *sel_comp, const optional &va template class TemplateSelect : public BaseTemplateSelect { public: + // User provided, not "= default": `new(p) TemplateSelect()` would zero-fill .bss that is already zero. + TemplateSelect() {} template void set_lambda(F &&f) { if constexpr (HAS_LAMBDA) { this->f_.set(std::forward(f)); From 925ddd6c09bcf5133092aa93d27f580c931278da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:00 -0500 Subject: [PATCH 139/316] [template] Use a user provided default constructor for TemplateEvent (#19127) --- esphome/components/template/event/template_event.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index fe83dc9f34..3d2d9a9efe 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -5,6 +5,10 @@ namespace esphome::template_ { -class TemplateEvent final : public Component, public event::Event {}; +class TemplateEvent final : public Component, public event::Event { + public: + // User provided, not "= default": `new(p) TemplateEvent()` would zero-fill .bss that is already zero. + TemplateEvent() {} +}; } // namespace esphome::template_ From 3180381a26d7fbf4a1423c9727250be85adee184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:07 -0500 Subject: [PATCH 140/316] [alarm_control_panel] Initialize the state members (#19120) --- .../components/alarm_control_panel/alarm_control_panel.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index e748b8621b..aced89b7ff 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -138,11 +138,11 @@ class AlarmControlPanel : public EntityBase { // in order to store last panel state in flash ESPPreferenceObject pref_; // current state - AlarmControlPanelState current_state_; + AlarmControlPanelState current_state_{ACP_STATE_DISARMED}; // the desired (or previous) state - AlarmControlPanelState desired_state_; + AlarmControlPanelState desired_state_{ACP_STATE_DISARMED}; // last time the state was updated - uint32_t last_update_; + uint32_t last_update_{0}; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - passes the new state to listeners From 7e750ec611a46377e85c793e21e8eb10792ab54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:10 -0500 Subject: [PATCH 141/316] [text_sensor] Use a user provided default constructor (#19112) --- esphome/components/text_sensor/text_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 0e7364bf98..5041ebc4e0 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,7 +29,8 @@ class TextSensor : public EntityBase { public: std::string state; - TextSensor() = default; + // User provided, not "= default": `new(p) TextSensor()` would zero-fill .bss that is already zero. + TextSensor() {} ~TextSensor() = default; /// Getter-syntax for .state. From a5931ea20eab43e8dad6ffe733a179dcd516c3c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:21 -0500 Subject: [PATCH 142/316] [fan] Initialize Fan::restore_mode_ (#19118) --- esphome/components/fan/fan.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 106e6e74cd..7e21971639 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -183,7 +183,7 @@ class Fan : public EntityBase { LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; - FanRestoreMode restore_mode_; + FanRestoreMode restore_mode_{FanRestoreMode::NO_RESTORE}; private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). From 17c5daa24560607314c7dcd501a5dca4bb8f65e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:33 -0500 Subject: [PATCH 143/316] [ld2412] Drop the unused gate index from GateThresholdNumber (#19107) --- esphome/components/ld2412/number/__init__.py | 4 ++-- esphome/components/ld2412/number/gate_threshold_number.cpp | 2 -- esphome/components/ld2412/number/gate_threshold_number.h | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index 1a81c330ad..f27e241491 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -109,14 +109,14 @@ async def to_code(config: ConfigType) -> None: for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) + n = cg.new_Pvariable(move_config[CONF_ID]) await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) await cg.register_parented(n, config[CONF_LD2412_ID]) cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) + n = cg.new_Pvariable(still_config[CONF_ID]) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 8d12bad115..a0a525a810 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -2,8 +2,6 @@ namespace esphome::ld2412 { -GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} - void GateThresholdNumber::control(float value) { this->publish_state(value); this->parent_->set_gate_threshold(); diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 918b6dfad1..308da43a34 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -7,10 +7,10 @@ namespace esphome::ld2412 { class GateThresholdNumber final : public number::Number, public Parented { public: - GateThresholdNumber(uint8_t gate); + // Not "= default": that makes new(p) T() zero-fill the object at every codegen site before the ctor runs. + GateThresholdNumber() {} protected: - uint8_t gate_; void control(float value) override; }; From fd011ec3379382978ea96131ec19e15d457b047c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:19 -0500 Subject: [PATCH 144/316] [esp8266_pwm] Use a user provided default constructor for ESP8266PWM (#19198) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 79c2e50984..87b76a392a 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -11,6 +11,9 @@ namespace esphome::esp8266_pwm { class ESP8266PWM final : public output::FloatOutput, public Component { public: + // User provided, not "= default": `new(p) ESP8266PWM()` would zero-fill .bss that is already zero. + ESP8266PWM() {} + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency @@ -28,7 +31,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { protected: void write_state(float state) override; - InternalGPIOPin *pin_; + InternalGPIOPin *pin_{nullptr}; float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; From 0f01fa89b8661b828415c50d1be4e1c1a5b544cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:25 -0500 Subject: [PATCH 145/316] [api] Skip setters that pass the default port, reboot timeout and batch delay (#19227) --- esphome/components/api/__init__.py | 24 +++++++++---- esphome/components/api/api_server.h | 6 ++-- tests/component_tests/api/config/bare.yaml | 12 +++++++ tests/component_tests/api/config/custom.yaml | 15 ++++++++ .../component_tests/api/config/defaults.yaml | 15 ++++++++ .../api/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/api/config/bare.yaml create mode 100644 tests/component_tests/api/config/custom.yaml create mode 100644 tests/component_tests/api/config/defaults.yaml create mode 100644 tests/component_tests/api/test_default_setters.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 272b078690..854bceecfa 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -136,6 +136,12 @@ CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +# Schema defaults that also match the C++ initializers in api_server.h; codegen +# skips the setter when the config equals them. +DEFAULT_PORT = 6053 +DEFAULT_REBOOT_TIMEOUT = "15min" +DEFAULT_BATCH_DELAY = "100ms" + def _register_provisioning_source(config: ConfigType) -> ConfigType: """Register the API as a provisioning source when encryption is enabled. @@ -292,7 +298,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(APIServer), - cv.Optional(CONF_PORT, default=6053): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, # Removed in 2026.1.0 - kept to provide helpful error message cv.Optional(CONF_PASSWORD): cv.invalid( "The 'password' option has been removed in ESPHome 2026.1.0.\n" @@ -305,14 +311,14 @@ CONFIG_SCHEMA = cv.All( "Or visit https://esphome.io/components/api/#configuration-variables" ), cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Exclusive( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, - cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( + cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), @@ -462,9 +468,15 @@ async def to_code(config: ConfigType) -> None: # Request a log listener slot for API log streaming request_log_listener() - cg.add(var.set_port(config[CONF_PORT])) - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + # Skip the setters when the config matches the C++ initializers (DEFAULT_*). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(var.set_port(port)) + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + 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])) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11..e5a22dcef8 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -314,7 +314,7 @@ class APIServer final : public Component, #endif // 4-byte aligned types - uint32_t reboot_timeout_{300000}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. @@ -351,8 +351,8 @@ class APIServer final : public Component, #endif // Group smaller types together - uint16_t port_{6053}; - uint16_t batch_delay_{100}; + 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}; diff --git a/tests/component_tests/api/config/bare.yaml b/tests/component_tests/api/config/bare.yaml new file mode 100644 index 0000000000..be5c73f18b --- /dev/null +++ b/tests/component_tests/api/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: diff --git a/tests/component_tests/api/config/custom.yaml b/tests/component_tests/api/config/custom.yaml new file mode 100644 index 0000000000..cdf4038d5d --- /dev/null +++ b/tests/component_tests/api/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6054 + reboot_timeout: 0s + batch_delay: 0ms diff --git a/tests/component_tests/api/config/defaults.yaml b/tests/component_tests/api/config/defaults.yaml new file mode 100644 index 0000000000..b20fd9b884 --- /dev/null +++ b/tests/component_tests/api/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6053 + reboot_timeout: 15min + batch_delay: 100ms diff --git a/tests/component_tests/api/test_default_setters.py b/tests/component_tests/api/test_default_setters.py new file mode 100644 index 0000000000..32d35cacb7 --- /dev/null +++ b/tests/component_tests/api/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that the api component only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + 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. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + 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 + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + 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 From 0d5683525e0f4b12367ff5a2a09a0b4df7b66786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:29 -0500 Subject: [PATCH 146/316] [core] Use a user provided default constructor for DelayAction (#19137) --- esphome/core/base_automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 276b8aa972..999b38bd5c 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -180,7 +180,9 @@ class ProjectUpdateTrigger : public Trigger, public Component { template class DelayAction : public Action { public: - explicit DelayAction() = default; + // User provided, not "= default": `new(p) DelayAction()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr explicit DelayAction() noexcept {} TEMPLATABLE_VALUE(uint32_t, delay) From 0d4c9f3243b0e6e7ae9d026d641ccd0b8ffa9a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:32 -0500 Subject: [PATCH 147/316] [core] Use a user provided default constructor for Automation (#19197) --- esphome/core/automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ea522a4d2d..5f010521dc 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -608,7 +608,9 @@ template class ActionList { template class Automation { public: /// Default constructor for use with TriggerForwarder (no Trigger object needed). - Automation() = default; + // User provided, not "= default": `new(p) Automation()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr Automation() noexcept {} explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } From dd40678ad70be06405070115a7e0855cb66bf278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:37 -0500 Subject: [PATCH 148/316] [pca9554] Use a user provided default constructor for PCA9554GPIOPin (#19208) --- esphome/components/pca9554/pca9554.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 05e945d176..cc95f147ac 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -55,6 +55,9 @@ class PCA9554Component final : public Component, /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. class PCA9554GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. + PCA9554GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -69,10 +72,10 @@ class PCA9554GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCA9554Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCA9554Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pca9554 From 237d87880fe4709e00c2ca76182fc184d8729985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:42 -0500 Subject: [PATCH 149/316] [pcf8574] Use a user provided default constructor for PCF8574GPIOPin (#19207) --- esphome/components/pcf8574/pcf8574.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index e8f78bae50..9879d6a47e 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -51,6 +51,9 @@ class PCF8574Component final : public Component, /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. class PCF8574GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. + PCF8574GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -65,10 +68,10 @@ class PCF8574GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCF8574Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCF8574Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pcf8574 From 4c7aee1a77cbe30249a0132f19f0a6e9c3db97a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:49:56 +1200 Subject: [PATCH 150/316] [improv_ble] Rename from esp32_improv and decouple from ESP32 (#19264) --- CODEOWNERS | 2 +- esphome/component_aliases.py | 1 + .../components/esp32_ble_server/__init__.py | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- esphome/components/improv_base/__init__.py | 6 +- .../components/improv_base/improv_base.cpp | 2 +- esphome/components/improv_base/improv_base.h | 6 +- .../{esp32_improv => improv_ble}/__init__.py | 90 +++++++++++++------ .../{esp32_improv => improv_ble}/automation.h | 38 ++++---- .../improv_ble_component.cpp} | 69 +++++++------- .../improv_ble_component.h} | 24 ++--- esphome/components/improv_serial/__init__.py | 2 +- .../improv_serial/improv_serial_component.cpp | 2 +- .../improv_serial/improv_serial_component.h | 2 +- esphome/components/wifi/__init__.py | 2 +- esphome/components/wifi/wifi_component.cpp | 44 ++++----- esphome/components/wifi/wifi_component.h | 2 +- esphome/core/defines.h | 7 +- platformio.ini | 2 +- .../esp32_ble_server/config/improv_only.yaml | 2 +- .../esp32_ble_server/test_esp32_ble_server.py | 2 +- tests/component_tests/improv_ble/__init__.py | 0 .../improv_ble/config/automations.yaml | 31 +++++++ .../improv_ble/config/esp32.yaml | 12 +++ .../improv_ble/config/esp8266.yaml | 10 +++ .../improv_ble/config/legacy_key.yaml | 12 +++ .../improv_ble/test_improv_ble.py | 59 ++++++++++++ .../improv_base/rpc_response_builder_test.cpp | 2 +- .../{esp32_improv => improv_ble}/common.yaml | 2 +- .../test.esp32-c3-idf.yaml | 0 .../test.esp32-idf.yaml | 0 .../improv_serial/common-uart0.yaml | 2 +- .../provisioning/test.esp32-idf.yaml | 4 +- 33 files changed, 303 insertions(+), 140 deletions(-) rename esphome/components/{esp32_improv => improv_ble}/__init__.py (63%) rename esphome/components/{esp32_improv => improv_ble}/automation.h (55%) rename esphome/components/{esp32_improv/esp32_improv_component.cpp => improv_ble/improv_ble_component.cpp} (91%) rename esphome/components/{esp32_improv/esp32_improv_component.h => improv_ble/improv_ble_component.h} (86%) create mode 100644 tests/component_tests/improv_ble/__init__.py create mode 100644 tests/component_tests/improv_ble/config/automations.yaml create mode 100644 tests/component_tests/improv_ble/config/esp32.yaml create mode 100644 tests/component_tests/improv_ble/config/esp8266.yaml create mode 100644 tests/component_tests/improv_ble/config/legacy_key.yaml create mode 100644 tests/component_tests/improv_ble/test_improv_ble.py rename tests/components/{esp32_improv => improv_ble}/common.yaml (96%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-c3-idf.yaml (100%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-idf.yaml (100%) diff --git a/CODEOWNERS b/CODEOWNERS index 044d005119..9b34d523d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -182,7 +182,6 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 -esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -268,6 +267,7 @@ esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core +esphome/components/improv_ble/* @jesserockz esphome/components/improv_serial/* @esphome/core esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py index e701bd98d4..53a34d1e15 100644 --- a/esphome/component_aliases.py +++ b/esphome/component_aliases.py @@ -6,5 +6,6 @@ See the component-alias section of esphome/loader.py. # alias -> (canonical component, removal version or None) COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "esp32_improv": ("improv_ble", "2027.4.0"), "rp2040": ("rp2", "2027.7.0"), } diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 118ae06e42..924d11db2b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -597,7 +597,7 @@ async def to_code(config): cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) # Only advertise for the server itself when the configuration gives clients something to - # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. cg.add( var.set_advertising_required( diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7869c73cc5..e469b60e08 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -40,7 +40,7 @@ class BLEServer final : public Component, public Parented { /** Whether this server needs the device to advertise so clients can find and connect to it. * - * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * False for a server that only hosts services created at runtime (e.g. improv_ble), which * request advertising themselves for as long as they need it. */ void set_advertising_required(bool required) { this->advertising_required_ = required; } diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 412d143a48..9b57b6561f 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -38,9 +38,11 @@ def _process_next_url(url: str) -> str: return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: +async def setup_improv_core(var: MockObj, config: ConfigType) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) - cg.add_define(f"USE_{component.upper()}_NEXT_URL") + # One define for all transports: next_url_ is per object, so a transport + # configured without next_url: calls add_next_url_ and appends nothing. + cg.add_define("USE_IMPROV_NEXT_URL") cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 1babeb5b5a..6745f8064b 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -8,7 +8,7 @@ namespace esphome::improv_base { -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL static const char *const TAG = "improv_base"; static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 352bb75d5f..97801302d4 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL #include #endif @@ -11,12 +11,12 @@ namespace esphome::improv_base { class ImprovBase { public: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL void set_next_url(const char *next_url) { this->next_url_ = next_url; } #endif protected: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); /// Append the formatted next_url to the RPC response, warning if it does not fit. diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/improv_ble/__init__.py similarity index 63% rename from esphome/components/esp32_improv/__init__.py rename to esphome/components/improv_ble/__init__.py index 32eb166014..72ac586628 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,41 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, improv_base, output -from esphome.components.esp32_ble import BTLoggers +from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import ( + CONF_ID, + CONF_ON_START, + CONF_ON_STATE, + CONF_TRIGGER_ID, + PLATFORM_ESP32, +) +from esphome.core import CORE from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_server", "improv_base"] +# The BLE GATT server component that hosts the Improv service, per target +# platform. improv_ble itself is platform neutral; supporting another chip +# means adding its BLE server component here and the matching backend in +# improv_ble_component.cpp. Doubles as the platform gate below, so an +# unsupported chip is rejected in validation rather than at link time. +BLE_SERVER_BACKENDS: dict[str, str] = { + PLATFORM_ESP32: "esp32_ble_server", +} + + +def AUTO_LOAD() -> list[str]: + auto_load = ["improv_base"] + if backend := BLE_SERVER_BACKENDS.get(CORE.target_platform): + auto_load.append(backend) + return auto_load + + CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["wifi", "esp32"] +DEPENDENCIES = ["wifi"] + +# Legacy top-level YAML key that routes here; esphome/loader.py and +# esphome/config.py handle the warning and the key rename. +ALIASES = ["esp32_improv"] +ALIAS_REMOVAL_VERSION = "2027.4.0" CONF_AUTHORIZED_DURATION = "authorized_duration" CONF_AUTHORIZER = "authorizer" @@ -29,29 +56,29 @@ improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") -esp32_improv_ns = cg.esphome_ns.namespace("esp32_improv") -ESP32ImprovComponent = esp32_improv_ns.class_("ESP32ImprovComponent", cg.Component) -ESP32ImprovProvisionedTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisionedTrigger", automation.Trigger.template() +improv_ble_ns = cg.esphome_ns.namespace("improv_ble") +ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) +ImprovBLEProvisionedTrigger = improv_ble_ns.class_( + "ImprovBLEProvisionedTrigger", automation.Trigger.template() ) -ESP32ImprovProvisioningTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisioningTrigger", automation.Trigger.template() +ImprovBLEProvisioningTrigger = improv_ble_ns.class_( + "ImprovBLEProvisioningTrigger", automation.Trigger.template() ) -ESP32ImprovStartTrigger = esp32_improv_ns.class_( - "ESP32ImprovStartTrigger", automation.Trigger.template() +ImprovBLEStartTrigger = improv_ble_ns.class_( + "ImprovBLEStartTrigger", automation.Trigger.template() ) -ESP32ImprovStateTrigger = esp32_improv_ns.class_( - "ESP32ImprovStateTrigger", automation.Trigger.template() +ImprovBLEStateTrigger = improv_ble_ns.class_( + "ImprovBLEStateTrigger", automation.Trigger.template() ) -ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( - "ESP32ImprovStoppedTrigger", automation.Trigger.template() +ImprovBLEStoppedTrigger = improv_ble_ns.class_( + "ImprovBLEStoppedTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.GenerateID(): cv.declare_id(ImprovBLEComponent), cv.Required(CONF_AUTHORIZER): cv.Any( cv.none, cv.use_id(binary_sensor.BinarySensor) ), @@ -68,55 +95,60 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger + ImprovBLEProvisionedTrigger ), } ), cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger + ImprovBLEProvisioningTrigger ), } ), cv.Optional(CONF_ON_START): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStartTrigger + ImprovBLEStartTrigger ), } ), cv.Optional(CONF_ON_STATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStateTrigger + ImprovBLEStateTrigger ), } ), cv.Optional(CONF_ON_STOP): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger + ImprovBLEStoppedTrigger ), } ), } ) .extend(improv_base.IMPROV_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on(list(BLE_SERVER_BACKENDS)), ) async def to_code(config: ConfigType) -> None: + # ESP32 backend setup: the platform gate above means this is the only backend + # that can reach to_code. Make it conditional when a second one is added. + from esphome.components import esp32_ble + # Register the loggers this component needs - esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + esp32_ble.register_bt_logger(esp32_ble.BTLoggers.GATT, esp32_ble.BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - cg.add_define("USE_IMPROV") + cg.add_define("USE_IMPROV_BLE") - await improv_base.setup_improv_core(var, config, "esp32_improv") + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) @@ -155,4 +187,4 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_ESP32_IMPROV_STATE_CALLBACK") + cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/improv_ble/automation.h similarity index 55% rename from esphome/components/esp32_improv/automation.h rename to esphome/components/improv_ble/automation.h index b3b61f4778..223a129238 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/improv_ble/automation.h @@ -1,17 +1,17 @@ #pragma once #ifdef USE_ESP32 -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK -#include "esp32_improv_component.h" +#ifdef USE_IMPROV_BLE_STATE_CALLBACK +#include "improv_ble_component.h" #include "esphome/core/automation.h" #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { -class ESP32ImprovProvisionedTrigger final : public Trigger<> { +class ImprovBLEProvisionedTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { this->trigger(); @@ -20,12 +20,12 @@ class ESP32ImprovProvisionedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovProvisioningTrigger final : public Trigger<> { +class ImprovBLEProvisioningTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { this->trigger(); @@ -34,12 +34,12 @@ class ESP32ImprovProvisioningTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStartTrigger final : public Trigger<> { +class ImprovBLEStartTrigger final : public Trigger<> { public: - explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && !this->parent_->is_failed()) { @@ -49,12 +49,12 @@ class ESP32ImprovStartTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStateTrigger final : public Trigger { public: - explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (!this->parent_->is_failed()) { this->trigger(state, error); @@ -63,12 +63,12 @@ class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStoppedTrigger final : public Trigger<> { public: - explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { this->trigger(); @@ -77,10 +77,10 @@ class ESP32ImprovStoppedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp similarity index 91% rename from esphome/components/esp32_improv/esp32_improv_component.cpp rename to esphome/components/improv_ble/improv_ble_component.cpp index 9ec6eb7bab..bbc1589abf 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -1,10 +1,7 @@ -#include "esp32_improv_component.h" +#include "improv_ble_component.h" #include -#include "esphome/components/bytebuffer/bytebuffer.h" -#include "esphome/components/esp32_ble/ble.h" -#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,11 +12,15 @@ #ifdef USE_ESP32 -namespace esphome::esp32_improv { +#include "esphome/components/bytebuffer/bytebuffer.h" +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/components/esp32_ble_server/ble_2902.h" + +namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "esp32_improv.component"; +static const char *const TAG = "improv_ble.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -38,9 +39,9 @@ static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 -ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } +ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } -void ESP32ImprovComponent::setup() { +void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR if (this->authorizer_ != nullptr) { this->authorizer_->add_on_state_callback([this](bool state) { @@ -66,7 +67,7 @@ void ESP32ImprovComponent::setup() { this->disable_loop(); } -void ESP32ImprovComponent::setup_characteristics() { +void ImprovBLEComponent::setup_characteristics() { this->status_ = this->service_->create_characteristic( improv::STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLEDescriptor *status_descriptor = new BLE2902(); @@ -104,11 +105,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->setup_complete_ = true; } -void ESP32ImprovComponent::loop() { +void ImprovBLEComponent::loop() { if (!global_ble_server->is_running()) { if (this->state_ != improv::STATE_STOPPED) { this->state_ = improv::STATE_STOPPED; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } @@ -200,7 +201,7 @@ void ESP32ImprovComponent::loop() { } } -void ESP32ImprovComponent::set_status_indicator_state_(bool state) { +void ImprovBLEComponent::set_status_indicator_state_(bool state) { #ifdef USE_OUTPUT if (this->status_indicator_ == nullptr) return; @@ -216,7 +217,7 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -const char *ESP32ImprovComponent::state_to_string_(improv::State state) { +const char *ImprovBLEComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: return "STOPPED"; @@ -234,7 +235,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { } #endif -bool ESP32ImprovComponent::check_identify_() { +bool ImprovBLEComponent::check_identify_() { uint32_t now = millis(); bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_; @@ -246,7 +247,7 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { +void ImprovBLEComponent::set_state_(improv::State state, bool update_advertising) { // Skip if state hasn't changed if (this->state_ == state) { return; @@ -274,12 +275,12 @@ void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertisi // Advertise the new state via service data this->advertise_service_data_(); } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } -void ESP32ImprovComponent::set_error_(improv::Error error) { +void ImprovBLEComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } @@ -295,14 +296,14 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::span response) { +void ImprovBLEComponent::send_response_(std::span response) { // The BLE characteristic owns its value, so one exact-size copy is required here this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } -void ESP32ImprovComponent::start() { +void ImprovBLEComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; @@ -320,7 +321,7 @@ void ESP32ImprovComponent::start() { this->enable_loop(); } -void ESP32ImprovComponent::stop() { +void ImprovBLEComponent::stop() { this->should_start_ = false; // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing @@ -335,10 +336,10 @@ void ESP32ImprovComponent::stop() { }); } -float ESP32ImprovComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } -void ESP32ImprovComponent::dump_config() { - ESP_LOGCONFIG(TAG, "ESP32 Improv:"); +void ImprovBLEComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); #endif @@ -347,7 +348,7 @@ void ESP32ImprovComponent::dump_config() { #endif } -void ESP32ImprovComponent::process_incoming_data_() { +void ImprovBLEComponent::process_incoming_data_() { if (this->incoming_data_.size() < 3) return; uint8_t length = this->incoming_data_[1]; @@ -422,7 +423,7 @@ void ESP32ImprovComponent::process_incoming_data_() { } } -void ESP32ImprovComponent::on_wifi_connect_timeout_() { +void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR @@ -433,7 +434,7 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } -void ESP32ImprovComponent::check_wifi_connection_() { +void ImprovBLEComponent::check_wifi_connection_() { if (!wifi::global_wifi_component->is_connected()) { return; } @@ -447,7 +448,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { std::array buf; improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); -#ifdef USE_ESP32_IMPROV_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif @@ -480,7 +481,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->stop(); } -void ESP32ImprovComponent::advertise_service_data_() { +void ImprovBLEComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR service_data[1] = IMPROV_PROTOCOL_ID_2; // IM @@ -499,7 +500,7 @@ void ESP32ImprovComponent::advertise_service_data_() { esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); } -void ESP32ImprovComponent::update_advertising_type_() { +void ImprovBLEComponent::update_advertising_type_() { uint32_t now = App.get_loop_component_start_time(); // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data @@ -524,21 +525,21 @@ void ESP32ImprovComponent::update_advertising_type_() { } } -void ESP32ImprovComponent::request_advertising_() { +void ImprovBLEComponent::request_advertising_() { if (this->advertising_requested_) return; this->advertising_requested_ = true; esp32_ble::global_ble->advertising_start(); } -void ESP32ImprovComponent::release_advertising_() { +void ImprovBLEComponent::release_advertising_() { if (!this->advertising_requested_) return; this->advertising_requested_ = false; esp32_ble::global_ble->advertising_stop(); } -improv::State ESP32ImprovComponent::get_initial_state_() const { +improv::State ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; @@ -548,8 +549,8 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { #endif } -ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +ImprovBLEComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/improv_ble/improv_ble_component.h similarity index 86% rename from esphome/components/esp32_improv/esp32_improv_component.h rename to esphome/components/improv_ble/improv_ble_component.h index a40d60552a..2552bed69b 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -5,12 +5,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -#include "esphome/components/esp32_ble_server/ble_characteristic.h" -#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK #include "esphome/core/automation.h" #endif @@ -25,17 +23,23 @@ #include #include +// ESP-IDF is currently the only target platform with a BLE GATT server, so it is +// the only backend this component has. The Python side keeps the platform table +// (BLE_SERVER_BACKENDS in __init__.py); a second backend adds another arm here. #ifdef USE_ESP32 +#include "esphome/components/esp32_ble_server/ble_characteristic.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { using namespace esp32_ble_server; -class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { +class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { public: - ESP32ImprovComponent(); + ImprovBLEComponent(); void dump_config() override; void loop() override; void setup() override; @@ -47,7 +51,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool should_start() const { return this->should_start_; } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); } @@ -97,7 +101,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB improv::State state_{improv::STATE_STOPPED}; improv::Error error_state_{improv::ERROR_NONE}; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif @@ -125,8 +129,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern ESP32ImprovComponent *global_improv_component; +extern ImprovBLEComponent *global_improv_component; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a34e2ab793..0231791e9b 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -70,7 +70,7 @@ FINAL_VALIDATE_SCHEMA = validate_transport async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await improv_base.setup_improv_core(var, config, "improv_serial") + await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: cg.add(var.set_uart(await cg.get_variable(uart_id))) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ffa7b79d9b..3827fb6ed4 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -208,7 +208,7 @@ void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &buil void ImprovSerialComponent::send_settings_response_(improv::Command command) { std::array buf; improv::RpcResponseBuilder builder(buf, command); -#ifdef USE_IMPROV_SERIAL_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 68cdd75214..c7d89c76d6 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -55,7 +55,7 @@ static const uint8_t IMPROV_SERIAL_VERSION = 1; #ifdef USE_WIFI // Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while // switching networks on an already-connected device (disconnect + reconnect) can legitimately -// take longer; 90 s matches esp32_improv's default wifi_timeout. +// take longer; 90 s matches improv_ble's default wifi_timeout. static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; #endif diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index a1a3436d47..c22d49e665 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -355,7 +355,7 @@ def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() - has_improv = "esp32_improv" in full_config + has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ba3614394..125139ad16 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,8 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_IMPROV -#include "esphome/components/esp32_improv/esp32_improv_component.h" +#ifdef USE_IMPROV_BLE +#include "esphome/components/improv_ble/improv_ble_component.h" #endif #ifdef USE_IMPROV_SERIAL @@ -226,7 +226,7 @@ bool CompactString::operator==(const StringRef &other) const { /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ -/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ When captive_portal or improv_ble is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ @@ -478,9 +478,9 @@ bool WiFiComponent::needs_full_scan_results_() const { } #endif -#ifdef USE_IMPROV +#ifdef USE_IMPROV_BLE // BLE improv also needs results during provisioning - if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { + if (improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active()) { return true; } #endif @@ -746,10 +746,10 @@ void WiFiComponent::start() { #endif #endif // USE_WIFI_AP } -#ifdef USE_IMPROV - if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { +#ifdef USE_IMPROV_BLE + if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); @@ -805,7 +805,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -894,12 +894,12 @@ void WiFiComponent::loop() { } #endif // USE_WIFI_AP -#ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && - !esp32_improv::global_improv_component->should_start()) { - if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { +#ifdef USE_IMPROV_BLE + if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && + !improv_ble::global_improv_component->should_start()) { + if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } } @@ -1644,9 +1644,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } -#ifdef USE_IMPROV - if (this->is_esp32_improv_active_()) { - esp32_improv::global_improv_component->stop(); +#ifdef USE_IMPROV_BLE + if (this->is_improv_ble_active_()) { + improv_ble::global_improv_component->stop(); } #endif @@ -1878,7 +1878,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_esp32_improv_active_()) { + } else if (this->is_improv_ble_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1969,7 +1969,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2228,9 +2228,9 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } -bool WiFiComponent::is_esp32_improv_active_() { -#ifdef USE_IMPROV - return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); +bool WiFiComponent::is_improv_ble_active_() { +#ifdef USE_IMPROV_BLE + return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); #else return false; #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a0983545fb..6791379649 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -797,7 +797,7 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); - bool is_esp32_improv_active_(); + bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b36d39bbef..b2b5267b11 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,7 +70,6 @@ #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE -#define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN @@ -83,6 +82,7 @@ #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE +#define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF #define USE_JSON @@ -266,7 +266,7 @@ #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL -#define USE_IMPROV_SERIAL_NEXT_URL +#define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE #define USE_SHA256 @@ -392,8 +392,7 @@ #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C -#define USE_IMPROV -#define USE_ESP32_IMPROV_NEXT_URL +#define USE_IMPROV_BLE #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN diff --git a/platformio.ini b/platformio.ini index 0e334ac5b4..722109adec 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,7 +46,7 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea esphome/noise-c@0.1.30 ; noise (api, ota) - improv/Improv@1.2.7 ; improv_serial / esp32_improv + improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image diff --git a/tests/component_tests/esp32_ble_server/config/improv_only.yaml b/tests/component_tests/esp32_ble_server/config/improv_only.yaml index 8a5c3ba638..4239d24b0f 100644 --- a/tests/component_tests/esp32_ble_server/config/improv_only.yaml +++ b/tests/component_tests/esp32_ble_server/config/improv_only.yaml @@ -9,5 +9,5 @@ wifi: password: password1 # esp32_ble_server is only auto-loaded here, so it has no services of its own. -esp32_improv: +improv_ble: authorizer: none diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 4b7ab79a81..21a12d9cf2 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: @pytest.mark.parametrize( ("config_file", "required"), [ - # Auto-loaded by esp32_improv only: nothing to find until Improv asks for it + # Auto-loaded by improv_ble only: nothing to find until Improv asks for it ("improv_only.yaml", False), # The configuration defines a service clients are meant to connect to ("own_service.yaml", True), diff --git a/tests/component_tests/improv_ble/__init__.py b/tests/component_tests/improv_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/improv_ble/config/automations.yaml b/tests/component_tests/improv_ble/config/automations.yaml new file mode 100644 index 0000000000..d5d97f5cbf --- /dev/null +++ b/tests/component_tests/improv_ble/config/automations.yaml @@ -0,0 +1,31 @@ +esphome: + name: improv-ble-automations +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button +output: + - platform: gpio + pin: 2 + id: built_in_led +improv_ble: + authorizer: io0_button + status_indicator: built_in_led + on_provisioned: + - logger.log: provisioned + on_provisioning: + - logger.log: provisioning + on_start: + - logger.log: start + on_state: + - logger.log: state + on_stop: + - logger.log: stop diff --git a/tests/component_tests/improv_ble/config/esp32.yaml b/tests/component_tests/improv_ble/config/esp32.yaml new file mode 100644 index 0000000000..ed55ef358a --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp32.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-esp32 +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/esp8266.yaml b/tests/component_tests/improv_ble/config/esp8266.yaml new file mode 100644 index 0000000000..d32defd6f3 --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp8266.yaml @@ -0,0 +1,10 @@ +esphome: + name: improv-ble-esp8266 +esp8266: + board: nodemcuv2 +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/legacy_key.yaml b/tests/component_tests/improv_ble/config/legacy_key.yaml new file mode 100644 index 0000000000..9491203ca9 --- /dev/null +++ b/tests/component_tests/improv_ble/config/legacy_key.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-legacy-key +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +esp32_improv: + authorizer: none diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py new file mode 100644 index 0000000000..02293bdb23 --- /dev/null +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -0,0 +1,59 @@ +"""improv_ble is platform neutral; only its BLE server backends are not. + +Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that +keeps pre-rename configurations working. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_esp32_generates_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("esp32.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + + +def test_legacy_key_routes_to_improv_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("legacy_key.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + assert "'esp32_improv:' top-level key is deprecated" in caplog.text + + +def test_platform_without_ble_server_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + # AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so + # the platform gate in CONFIG_SCHEMA is what has to reject the config. + CORE.config_path = component_config_path("esp8266.yaml") + assert read_config({}) is None + assert "only available on" in capsys.readouterr().out + + +def test_automations_emit_renamed_triggers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("automations.yaml")) + for trigger in ( + "ImprovBLEProvisionedTrigger", + "ImprovBLEProvisioningTrigger", + "ImprovBLEStartTrigger", + "ImprovBLEStateTrigger", + "ImprovBLEStoppedTrigger", + ): + assert f"improv_ble::{trigger}" in main_cpp + assert "set_authorizer" in main_cpp + assert "set_status_indicator" in main_cpp diff --git a/tests/components/improv_base/rpc_response_builder_test.cpp b/tests/components/improv_base/rpc_response_builder_test.cpp index d9d0ad90d8..f7f0eda38c 100644 --- a/tests/components/improv_base/rpc_response_builder_test.cpp +++ b/tests/components/improv_base/rpc_response_builder_test.cpp @@ -52,7 +52,7 @@ TEST(RpcResponseBuilder, GoldenBytes) { (std::vector{0x04, 0x03, 0x02, 'a', 'b', 0xCC})); } -// esp32_improv calls finish() and build_rpc_response() with no checksum flag, +// improv_ble calls finish() and build_rpc_response() with no checksum flag, // so the two defaults must agree TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) { const std::vector urls = {"https://example.com"}; diff --git a/tests/components/esp32_improv/common.yaml b/tests/components/improv_ble/common.yaml similarity index 96% rename from tests/components/esp32_improv/common.yaml rename to tests/components/improv_ble/common.yaml index 7dc2f7b6c7..7605cd6e65 100644 --- a/tests/components/esp32_improv/common.yaml +++ b/tests/components/improv_ble/common.yaml @@ -12,7 +12,7 @@ output: pin: 2 id: built_in_led -esp32_improv: +improv_ble: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led diff --git a/tests/components/esp32_improv/test.esp32-c3-idf.yaml b/tests/components/improv_ble/test.esp32-c3-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-c3-idf.yaml rename to tests/components/improv_ble/test.esp32-c3-idf.yaml diff --git a/tests/components/esp32_improv/test.esp32-idf.yaml b/tests/components/improv_ble/test.esp32-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-idf.yaml rename to tests/components/improv_ble/test.esp32-idf.yaml diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common-uart0.yaml index 45bf1e5c33..3710cb3bb5 100644 --- a/tests/components/improv_serial/common-uart0.yaml +++ b/tests/components/improv_serial/common-uart0.yaml @@ -5,6 +5,6 @@ wifi: logger: hardware_uart: UART0 -# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_ +# next_url compiles the USE_IMPROV_NEXT_URL branch and add_next_url_ improv_serial: next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}} diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml index baa3aa8f68..4a34539002 100644 --- a/tests/components/provisioning/test.esp32-idf.yaml +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -1,6 +1,6 @@ # Exercises the provisioning window: api registers as a provisioning source # (encryption enabled, no key), the on_timeout automation, and the wifi (AP + -# captive portal) and esp32_improv cross-component guards. improv_serial is +# captive portal) and improv_ble cross-component guards. improv_serial is # intentionally NOT gated. provisioning: timeout: 1min @@ -26,5 +26,5 @@ binary_sensor: pin: 0 id: io0_button -esp32_improv: +improv_ble: authorizer: io0_button From 3a9bb7e5bc6c4c22bf08f46ebccfb400b4287f0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 08:41:48 -0500 Subject: [PATCH 151/316] [http_request] Keep the update manifest URL as a pointer to the literal (#19211) --- .../update/http_request_update.cpp | 23 ++++++++++++------- .../http_request/update/http_request_update.h | 6 +++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 57dc86d55c..6a74c00e8e 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -1,5 +1,7 @@ #include "http_request_update.h" +#include + #include "esphome/core/application.h" #include "esphome/core/version.h" @@ -94,7 +96,7 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -174,21 +176,26 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - std::string path = info->firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - info->firmware_url = domain + path; + const char *source = this_update->source_url_; + const size_t source_len = strlen(source); + size_t prefix_len; + if (info->firmware_url[0] == '/') { + // scheme and host, up to the first slash after "https://" + const char *host_end = source_len > 8 ? strchr(source + 8, '/') : nullptr; + prefix_len = host_end != nullptr ? host_end - source : source_len; } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - info->firmware_url = domain + path; + // directory of the manifest, up to and including its last slash + const char *dir_end = strrchr(source, '/'); + prefix_len = dir_end != nullptr ? dir_end - source + 1 : 0; } + info->firmware_url.insert(0, source, prefix_len); } #ifdef ESPHOME_PROJECT_VERSION diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index be9fbf72bf..05a741b6cd 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -21,7 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const std::string &source_url) { this->source_url_ = source_url; } + void set_source_url(const char *source_url) { this->source_url_ = source_url; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -33,13 +33,15 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; - std::string source_url_; static void update_task(void *params); #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif uint8_t initial_check_remaining_{0}; + + private: + const char *source_url_{nullptr}; // literal from codegen }; } // namespace esphome::http_request From 544f8ae77175e086eab391cb9d958895506c8788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:14:52 -0500 Subject: [PATCH 152/316] [http_request] Take the URL and method as C strings (#19215) --- .../components/http_request/http_request.h | 64 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 19 +++--- .../http_request/http_request_arduino.h | 2 +- .../http_request/http_request_host.cpp | 27 ++++---- .../http_request/http_request_host.h | 2 +- .../http_request/http_request_idf.cpp | 21 +++--- .../http_request/http_request_idf.h | 2 +- 7 files changed, 85 insertions(+), 52 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 4471dffdc2..71668b8556 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -331,27 +331,46 @@ class HttpRequestComponent : public Component { void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } - std::shared_ptr get(const std::string &url) { - return this->start(url, "GET", "", std::vector
{}); - } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + std::shared_ptr get(const char *url) { return this->start(url, "GET", "", std::vector
{}); } + std::shared_ptr get(const char *url, const std::vector
&request_headers) { return this->start(url, "GET", "", request_headers); } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + std::shared_ptr get(const char *url, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->start(url, "GET", "", request_headers, lower_case_collect_headers); } - std::shared_ptr post(const std::string &url, const std::string &body) { + std::shared_ptr post(const char *url, const std::string &body) { return this->start(url, "POST", body, std::vector
{}); } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers) { + return this->start(url, "POST", body, request_headers); + } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + } + + std::shared_ptr get(const std::string &url) { return this->get(url.c_str()); } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + return this->get(url.c_str(), request_headers); + } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->get(url.c_str(), request_headers, lower_case_collect_headers); + } + std::shared_ptr post(const std::string &url, const std::string &body) { + return this->post(url.c_str(), body); + } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers) { - return this->start(url, "POST", body, request_headers); + return this->post(url.c_str(), body, request_headers); } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + return this->post(url.c_str(), body, request_headers, lower_case_collect_headers); } // Remove before 2027.1.0 @@ -379,11 +398,15 @@ class HttpRequestComponent : public Component { return this->post(url, body, std::vector
(request_headers.begin(), request_headers.end()), collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads return this->perform(url, method, body, request_headers, {}); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers); + } // Remove before 2027.1.0 ESPDEPRECATED("Pass request_headers as std::vector
instead of std::list. Removed in 2027.1.0.", "2026.7.0") @@ -403,7 +426,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); } // Remove before 2027.1.0 @@ -418,7 +441,8 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower); } // Remove before 2027.1.0 @@ -426,19 +450,25 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower_case_collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->perform(url, method, body, request_headers, lower_case_collect_headers); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers); + } protected: - virtual std::shared_ptr perform(const std::string &url, const std::string &method, - const std::string &body, const std::vector
&request_headers, + virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, const std::vector &lower_case_collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; @@ -499,8 +529,8 @@ template class HttpRequestSendAction final : public Actionparent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, - this->lower_case_collect_headers_); + auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, + request_headers, this->lower_case_collect_headers_); auto captured_args = std::make_tuple(x...); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 43ab2e5b53..0d968222e9 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -2,6 +2,8 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -22,8 +24,7 @@ static const char *const TAG = "http_request"; static constexpr int ESP8266_SSL_ERR_OOM = -1000; #endif -std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -37,7 +38,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur const uint32_t start = millis(); - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); @@ -70,19 +71,19 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS - bool status = container->client_.begin(*stream_ptr, url.c_str()); + bool status = container->client_.begin(*stream_ptr, url); #elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } - bool status = container->client_.begin(url.c_str()); + bool status = container->client_.begin(url); #endif App.feed_wdt(); if (!status) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.collectHeaders(header_keys, index); App.feed_wdt(); - container->status_code = container->client_.sendRequest(method.c_str(), body.c_str()); + container->status_code = container->client_.sendRequest(method, body.c_str()); App.feed_wdt(); if (container->status_code < 0) { #if defined(USE_ESP8266) && defined(USE_HTTP_REQUEST_ESP8266_HTTPS) @@ -139,7 +140,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } #endif - ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url.c_str(), + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); this->status_momentary_error("failed", 1000); @@ -147,7 +148,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return nullptr; } if (!is_success(container->status_code)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 028b9f44a1..62737f4d0d 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -54,7 +54,7 @@ class HttpRequestArduino final : public HttpRequestComponent { #endif protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; #ifdef USE_ESP8266 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index cf231e20bd..a788970202 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -5,6 +5,8 @@ #include "httplib.h" #include "http_request_host.h" +#include + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,8 +18,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request"; -std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -27,10 +28,10 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } std::regex url_regex(R"(^(([^:\/?#]+):)?(//([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)", std::regex::extended); - std::smatch url_match_result; + std::cmatch url_match_result; if (!std::regex_match(url, url_match_result, url_regex) || url_match_result.length() < 7) { - ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url); return nullptr; } auto host = url_match_result[4].str(); @@ -54,7 +55,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } httplib::Client client(scheme_host.c_str()); if (!client.is_valid()) { - ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; } client.set_follow_location(this->follow_redirects_); @@ -64,41 +65,41 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, #endif httplib::Result result; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { result = client.Get(path, h_headers, [&](const char *data, size_t data_length) { ESP_LOGV(TAG, "Got data length: %zu", data_length); container->response_body_.insert(container->response_body_.end(), (const uint8_t *) data, (const uint8_t *) data + data_length); return true; }); - } else if (method == "HEAD") { + } else if (strcmp(method, "HEAD") == 0) { result = client.Head(path, h_headers); - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { result = client.Put(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { result = client.Patch(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { result = client.Post(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } } else { - ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method.c_str(), url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method, url); container->end(); return nullptr; } App.feed_wdt(); if (!result) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url.c_str(), (unsigned) result.error()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, auto response = *result; container->status_code = response.status; if (!is_success(response.status)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), response.status); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 9045702f46..0ae9f2e27b 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,7 +18,7 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost final : public HttpRequestComponent { public: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 10313be89d..4e5a2c42b5 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -48,8 +50,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -59,15 +60,15 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } esp_http_client_method_t method_idf; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { method_idf = HTTP_METHOD_GET; - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { method_idf = HTTP_METHOD_POST; - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { method_idf = HTTP_METHOD_PUT; - } else if (method == "DELETE") { + } else if (strcmp(method, "DELETE") == 0) { method_idf = HTTP_METHOD_DELETE; - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { this->status_momentary_error("failed", ERROR_DURATION_MS); @@ -75,11 +76,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return nullptr; } - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; esp_http_client_config_t config = {}; - config.url = url.c_str(); + config.url = url; config.method = method_idf; config.timeout_ms = this->timeout_; config.disable_auto_redirect = !this->follow_redirects_; @@ -218,7 +219,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } } - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 1c062af81b..f84dc9576b 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -41,7 +41,7 @@ class HttpRequestIDF final : public HttpRequestComponent { void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; } protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE From e13d4247681cf3e62289b135cd6c5f7db18fa8bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:41:14 -0500 Subject: [PATCH 153/316] [speaker] Remove deprecated codec_support_enabled option (#19074) --- .../speaker/media_player/__init__.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90eb19d73d..e1808889f4 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -33,9 +31,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +39,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -103,15 +98,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +137,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, From 3804ec423f2ba3cc0a685a0278031221c8065bec Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 13:52:56 -0400 Subject: [PATCH 154/316] [i2s_audio] Resync DMA lockstep in place instead of restarting the speaker task (#19319) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 110 ++++++++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 39 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.h | 19 ++- .../speaker/i2s_audio_speaker_standard.cpp | 76 ++++++++---- 4 files changed, 160 insertions(+), 84 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0..ec4e459be7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 0c1140da0c..cb82b09f33 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -80,17 +80,6 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -325,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -342,6 +325,24 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; + } + xQueueReset(this->i2s_event_queue_); + + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 5812cc211b..b443166ea1 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -134,6 +132,21 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the /// speaker task only. /// @param data Pointer to audio sample data (modified in place) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d6..b4b6173458 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; From fdf6998a8635454fd96ceb89f7b153629984a0ac Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:08:40 -0400 Subject: [PATCH 155/316] [sendspin] Start mDNS service disabled, enable once server is running (#19326) --- esphome/components/mdns/mdns_component.cpp | 4 ++++ esphome/components/sendspin/__init__.py | 8 +++++++- esphome/components/sendspin/sendspin_hub.cpp | 20 +++++++++++++++++++- esphome/components/sendspin/sendspin_hub.h | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fa39e86ed0..9d1e585339 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -221,6 +221,10 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fda4d4f954..4b65cd1801 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( @@ -11,6 +11,7 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -285,6 +286,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2cb2b90995..3216a696fb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,10 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; +#endif + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -69,7 +73,21 @@ void SendspinHub::setup() { } } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, + // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. + if (!this->mdns_advertised_ && this->mdns_->is_ready()) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { + this->mdns_enable_attempt_ms_ = now; + this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); + } + } +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c66c7db3cc..daaeb2c9a5 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -10,6 +10,10 @@ #include "esphome/core/preferences.h" #include "esphome/core/version.h" +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif + #include #include #include @@ -135,6 +139,10 @@ class SendspinHub final : public Component, void set_model(const char *model) { this->model_ = model; } void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -287,6 +295,12 @@ class SendspinHub final : public Component, const char *manufacturer_{"ESPHome"}; const char *model_{nullptr}; // nullptr reports the device name instead const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; + uint32_t mdns_enable_attempt_ms_{0}; + bool mdns_advertised_{false}; +#endif }; /// @brief Base class for all sendspin subcomponents. From 6f15dc331f660a71fac552a61cf2177777a57577 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:33:41 +0000 Subject: [PATCH 156/316] Bump prek from 0.5.2 to 0.5.3 (#19358) 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 010c8243e7..81208d7cb7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.7 # .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.2 # .github/workflows/ci.yml reads this pin +prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests From 845de7b7ddd7defb6cc6ccc715d90d18127bfe70 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:38:18 -0400 Subject: [PATCH 157/316] [sendspin] Bump sendspin-cpp to v0.8.0 (#19357) --- esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- esphome/idf_component.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 4b65cd1801..49bee10936 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -282,7 +282,7 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3216a696fb..ca443c1840 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -66,8 +66,8 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); + if (!this->client_->start()) { + ESP_LOGE(TAG, "Failed to start Sendspin client"); this->mark_failed(); return; } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3cd5ee09b..95337007b8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.2 + version: 0.8.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From bafa096a1dadd0ec4a0ef718f063910068dbe091 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:19:47 -0500 Subject: [PATCH 158/316] [display] Trim the per pixel cost of draw_pixel_at (#19360) --- esphome/components/display/display.cpp | 5 ++++- esphome/components/display/display.h | 19 +++++++++++++++++++ esphome/components/display/display_buffer.cpp | 5 ++--- esphome/components/display/rect.cpp | 10 ---------- esphome/components/display/rect.h | 10 +++++++++- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/hub75/hub75.cpp | 5 ++--- esphome/components/it8951/it8951.cpp | 4 ++-- esphome/components/mipi_dsi/mipi_dsi.cpp | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 2 +- esphome/components/mipi_spi/mipi_spi.h | 2 +- esphome/components/pixoo/pixoo.cpp | 2 +- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 4 ++-- esphome/components/sdl/sdl_esphome.cpp | 2 +- esphome/components/st7701s/st7701s.cpp | 4 ++-- 15 files changed, 48 insertions(+), 30 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb60..66fadf12ec 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d..c138972149 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -758,6 +758,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +781,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +811,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c91914049..d564ea67bd 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f726917..3ecf6d1cf1 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88..d65d844b9e 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3214f932bf..3b3418d911 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -299,7 +299,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d..d36928a83a 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 179c2e5f63..237f1c3c8b 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0150cc2544..b6612038b6 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index c11044c288..3f83da7f80 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -259,7 +259,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7..550e1998bb 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -604,7 +604,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb17..aa035be347 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e..f2f25741f3 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021..a764b74581 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce5..47b200c2de 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { From 59a6760f5e4196d5b3f15cf481c1882bc2cbef40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:22 -0500 Subject: [PATCH 159/316] [nextion] Remove deprecated get_wave_chan_id() (#19080) --- esphome/components/nextion/nextion_component_base.h | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 5e84291b16..b66c0b9e4e 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -66,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -86,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; From 3d4765d05a5aa86f47cd2ac580ca2a197648263c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:28 -0500 Subject: [PATCH 160/316] [template] Remove deprecated bypass_before_arming() (#19075) --- .../alarm_control_panel/template_alarm_control_panel.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830..5888ce5e29 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. From 2f248390b282ca855b427c01584aacd473f0edf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:35 -0500 Subject: [PATCH 161/316] [modbus] Remove disable_crc validation stub (#19078) --- esphome/components/modbus/__init__.py | 16 +--------------- esphome/const.py | 1 - script/ci-custom.py | 2 +- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 0a34ed037d..fe93758726 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,13 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_CONTINUOUS, - CONF_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -285,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional( CONF_TURNAROUND_TIME, default="600ms" ): cv.positive_time_period_milliseconds, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -297,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/const.py b/esphome/const.py index fd95df4196..5ffbf8c49a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -352,7 +352,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" diff --git a/script/ci-custom.py b/script/ci-custom.py index e2b7cd8d37..2c9a64c68b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -710,7 +710,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) From 711733f9aff3e2380c2b8efe008257856af89d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:43 -0500 Subject: [PATCH 162/316] [modbus] Remove deprecated send() (#19079) --- esphome/components/modbus/modbus.h | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 1623c099a3..298cd9f527 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. From 417b841db76ce15d9c9a6f0d13f13d58fee88b7c Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Wed, 16 Sep 2026 14:53:53 -0700 Subject: [PATCH 163/316] [icnt86] Support ICNT86 touch driver (#18331) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icnt86/__init__.py | 1 + esphome/components/icnt86/icnt86.cpp | 84 +++++++++++++++++++++ esphome/components/icnt86/icnt86.h | 24 ++++++ esphome/components/icnt86/touchscreen.py | 40 ++++++++++ tests/components/icnt86/common.yaml | 24 ++++++ tests/components/icnt86/test.esp32-idf.yaml | 14 ++++ 7 files changed, 188 insertions(+) create mode 100644 esphome/components/icnt86/__init__.py create mode 100644 esphome/components/icnt86/icnt86.cpp create mode 100644 esphome/components/icnt86/icnt86.h create mode 100644 esphome/components/icnt86/touchscreen.py create mode 100644 tests/components/icnt86/common.yaml create mode 100644 tests/components/icnt86/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9b34d523d2..7f44f8323c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core esphome/components/improv_ble/* @jesserockz diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 0000000000..07f3b4e31c --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 0000000000..62a4586ebc --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 0000000000..0d96b01524 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 0000000000..5d7a738612 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/tests/components/icnt86/common.yaml b/tests/components/icnt86/common.yaml new file mode 100644 index 0000000000..1537bb8b76 --- /dev/null +++ b/tests/components/icnt86/common.yaml @@ -0,0 +1,24 @@ +touchscreen: + - platform: icnt86 + i2c_id: i2c_bus + interrupt_pin: ${interrupt_pin_touch} + reset_pin: ${reset_pin_touch} + display: epaper + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] + +display: + - platform: waveshare_epaper + id: epaper + rotation: 90 + cs_pin: ${cs_pin_display} + dc_pin: ${dc_pin_display} + busy_pin: ${busy_pin_display} + reset_pin: ${reset_pin_display} + model: 2.90inv2-r2 + pages: + - id: icnt86_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/icnt86/test.esp32-idf.yaml b/tests/components/icnt86/test.esp32-idf.yaml new file mode 100644 index 0000000000..a0b882292a --- /dev/null +++ b/tests/components/icnt86/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + interrupt_pin_touch: GPIO4 + reset_pin_touch: GPIO32 + cs_pin_display: GPIO33 + dc_pin_display: GPIO21 + busy_pin_display: GPIO27 + reset_pin_display: GPIO14 + clk_pin: GPIO25 + mosi_pin: GPIO26 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icnt86: !include common.yaml From cfcaeff27b376fdd63c3c2c9b8abdd3ad75f375b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:57:47 -0500 Subject: [PATCH 164/316] [esp32_hosted] Keep the update manifest URL as a pointer to the literal (#19212) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 4 ++-- esphome/components/esp32_hosted/update/esp32_hosted_update.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 4eb5d1745b..d9b375dd20 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -169,7 +169,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +215,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738d..c319852bff 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers From dc300ae5d451c0b6db98924fc9b512af331bf316 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:14 -0500 Subject: [PATCH 165/316] [esp8266] Drop the dead NEW_OOM_ABORT flag and its nothrow advice (#19247) --- esphome/components/esp8266/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29..cef0e6ea11 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -363,14 +363,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. # See throw_stubs.h for details. Must be prepended before , so this From 7386daed5fcb2937e97b3869c59232e66a56a3ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:45 -0500 Subject: [PATCH 166/316] [ld2450] Use a user provided default constructor for MultiTargetSwitch (#19192) --- esphome/components/ld2450/switch/multi_target_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce..d711a2d2d2 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; From 9288311978465380bca196eae6e890ecd6d112fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 17:19:04 -0500 Subject: [PATCH 167/316] [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) --- esphome/components/esphome/ota/__init__.py | 12 +++++++-- tests/component_tests/ota/test_esphome_ota.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260..bcf2a2271c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc..235ad902db 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" From bb5c06c58a7f2a9d56bcf5c8171caa4fab671110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:17:56 -0500 Subject: [PATCH 168/316] [image] Drop the unused stride_ member (#19353) --- esphome/components/image/image.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f20..fd9e92c21d 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif From f8012bc467492feb6cfd9f8e7348c62d452b62c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:22:32 -0500 Subject: [PATCH 169/316] [esp32] Redefine __FILE__ to the basename so assert paths stay out of RAM (#19106) --- esphome/components/esp32/__init__.py | 7 +++++++ .../esp32/config/file_macro_idf_5_0.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/component_tests/esp32/config/file_macro_idf_5_0.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d027c9a1c6..de25afa23b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2609,6 +2609,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml new file mode 100644 index 0000000000..22ee1e480e --- /dev/null +++ b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + version: 5.0.6 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 2dd2a50c83..777759e8af 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -658,6 +658,27 @@ def test_platformio_arduino_enables_reproducible_build( assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("reproducible_build.yaml", True), + ("reproducible_build_arduino.yaml", True), + ("file_macro_idf_5_0.yaml", False), + ], +) +def test_file_macro_is_basename_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + generate_main(component_config_path(config_file)) + + assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected + assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + + def test_native_idf_enables_reproducible_build( component_config_path: Callable[[str], Path], ) -> None: From abfe350119e48063540e8569830a7dd8a008fc01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:23:29 -0500 Subject: [PATCH 170/316] [core] Fold the modbus write and bits tests into the shared mesh fixture (#18946) --- .../fixtures/uart_mock_modbus_mesh.yaml | 292 +++++++++++++- ...rt_mock_modbus_server_controller_bits.yaml | 147 ------- ...t_mock_modbus_server_controller_write.yaml | 371 ------------------ tests/integration/test_uart_mock_modbus.py | 140 ++++--- 4 files changed, 340 insertions(+), 610 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 69edd614d7..977cdd359b 100644 --- a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -17,10 +17,10 @@ uart: baud_rate: 115200 port: /dev/null -# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only -# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second -# server hub. auto_start everywhere: the controller polls at boot, so the -# forwarding must already be live or early requests generate warnings. +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers +# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6 +# on the second server hub. auto_start everywhere: the controller polls at +# boot, so the forwarding must already be live or early requests generate warnings. # Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server @@ -64,6 +64,54 @@ globals: - id: stored_1 type: uint16_t initial_value: "0" + - id: stored_u_word + type: uint16_t + initial_value: "99" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" + - id: stored_s_word + type: int16_t + initial_value: "-99" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" + - id: stored_u_dword + type: uint32_t + initial_value: "16909060" + - id: stored_s_dword + type: int32_t + initial_value: "-16909060" + - id: stored_u_dword_r + type: uint32_t + initial_value: "67305985" + - id: stored_s_dword_r + type: int32_t + initial_value: "-67305985" + - id: stored_u_qword + type: uint64_t + initial_value: "72623859790382856" + - id: stored_s_qword + type: int64_t + initial_value: "-72623859790382856" + - id: stored_u_qword_r + type: uint64_t + initial_value: "578437695752307201" + - id: stored_s_qword_r + type: int64_t + initial_value: "-578437695752307201" + - id: stored_fp32 + type: float + initial_value: "3.14" + - id: stored_fp32_r + type: float + initial_value: "2.5" + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" modbus: - uart_id: virtual_uart_server @@ -90,6 +138,10 @@ modbus_controller: modbus_id: virtual_modbus_client id: modbus_controller_3 update_interval: 1s + - address: 6 + modbus_id: virtual_modbus_client + id: modbus_controller_6 + update_interval: 1s modbus_server: - address: 1 @@ -97,46 +149,60 @@ modbus_server: registers: - address: 0x01 value_type: U_WORD - read_lambda: return 99; + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; - address: 0x02 value_type: U_WORD_S - read_lambda: return 4660; + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD - read_lambda: return -99; + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; - address: 0x04 value_type: S_WORD_S - read_lambda: return -2; + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD - read_lambda: return 16909060; + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; - address: 0x08 value_type: S_DWORD - read_lambda: return -16909060; + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; - address: 0x0B value_type: U_DWORD_R - read_lambda: return 67305985; + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; - address: 0x0E value_type: S_DWORD_R - read_lambda: return -67305985; + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; - address: 0x11 value_type: U_QWORD - read_lambda: return 72623859790382856; + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; - address: 0x16 value_type: S_QWORD - read_lambda: return -72623859790382856; + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; - address: 0x1B value_type: U_QWORD_R - read_lambda: return 578437695752307201; + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; - address: 0x20 value_type: S_QWORD_R - read_lambda: return -578437695752307201; + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; - address: 0x25 value_type: FP32 - read_lambda: return 3.14; + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; - address: 0x28 value_type: FP32_R - read_lambda: return 3.14; + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; - address: 5 modbus_id: virtual_modbus_server registers: @@ -165,6 +231,19 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 929; + - address: 6 + modbus_id: virtual_modbus_server_2 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; sensor: - platform: modbus_controller @@ -280,6 +359,183 @@ sensor: name: "client_read_1" id: client_read_1 +# The number schema caps min/max at 16777215 (float32 integer precision), so +# the large dword/qword baselines cannot be written back through these numbers. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02); +# the server serves both from one shared table, so the two views must agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml deleted file mode 100644 index cb6fc6f074..0000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml +++ /dev/null @@ -1,147 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-bits - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_bit_2 - type: bool - initial_value: "false" - - id: stored_bit_3 - type: bool - initial_value: "true" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 1s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - bits: - - address: 0x00 - read_lambda: return true; - - address: 0x01 - read_lambda: return false; - - address: 0x02 - read_lambda: return id(stored_bit_2); - write_lambda: id(stored_bit_2) = x; return true; - - address: 0x03 - read_lambda: return id(stored_bit_3); - write_lambda: id(stored_bit_3) = x; return true; - -# The same four bits are read both as coils (FC 0x01) and as discrete inputs -# (FC 0x02): the server serves both from one shared bit table, so the two -# views must always agree. -binary_sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_0" - address: 0x00 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_1" - address: 0x01 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_3" - address: 0x03 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_0" - address: 0x00 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_1" - address: 0x01 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_2" - address: 0x02 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_3" - address: 0x03 - register_type: discrete_input - -# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the -# multiple-coils write (FC 0x0F) so both server write paths are exercised. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_3" - address: 0x03 - register_type: coil - use_write_multiple: true - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml deleted file mode 100644 index 5ade49bd48..0000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ /dev/null @@ -1,371 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_u_word - type: uint16_t - initial_value: "11" - - id: stored_u_word_s - type: uint16_t - initial_value: "4660" - - id: stored_s_word - type: int16_t - initial_value: "-11" - - id: stored_s_word_s - type: int16_t - initial_value: "-2" - - id: stored_u_dword - type: uint32_t - initial_value: "1001" - - id: stored_s_dword - type: int32_t - initial_value: "-1001" - - id: stored_u_dword_r - type: uint32_t - initial_value: "3003" - - id: stored_s_dword_r - type: int32_t - initial_value: "-3003" - - id: stored_u_qword - type: uint64_t - initial_value: "5005" - - id: stored_s_qword - type: int64_t - initial_value: "-5005" - - id: stored_u_qword_r - type: uint64_t - initial_value: "7007" - - id: stored_s_qword_r - type: int64_t - initial_value: "-7007" - - id: stored_fp32 - type: float - initial_value: "1.5" - - id: stored_fp32_r - type: float - initial_value: "2.5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 2s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return id(stored_u_word); - write_lambda: id(stored_u_word) = x; return true; - - address: 0x02 - value_type: U_WORD_S - read_lambda: return id(stored_u_word_s); - write_lambda: id(stored_u_word_s) = x; return true; - - address: 0x03 - value_type: S_WORD - read_lambda: return id(stored_s_word); - write_lambda: id(stored_s_word) = x; return true; - - address: 0x04 - value_type: S_WORD_S - read_lambda: return id(stored_s_word_s); - write_lambda: id(stored_s_word_s) = x; return true; - - address: 0x05 - value_type: U_DWORD - read_lambda: return id(stored_u_dword); - write_lambda: id(stored_u_dword) = x; return true; - - address: 0x08 - value_type: S_DWORD - read_lambda: return id(stored_s_dword); - write_lambda: id(stored_s_dword) = x; return true; - - address: 0x0B - value_type: U_DWORD_R - read_lambda: return id(stored_u_dword_r); - write_lambda: id(stored_u_dword_r) = x; return true; - - address: 0x0E - value_type: S_DWORD_R - read_lambda: return id(stored_s_dword_r); - write_lambda: id(stored_s_dword_r) = x; return true; - - address: 0x11 - value_type: U_QWORD - read_lambda: return id(stored_u_qword); - write_lambda: id(stored_u_qword) = x; return true; - - address: 0x16 - value_type: S_QWORD - read_lambda: return id(stored_s_qword); - write_lambda: id(stored_s_qword) = x; return true; - - address: 0x1B - value_type: U_QWORD_R - read_lambda: return id(stored_u_qword_r); - write_lambda: id(stored_u_qword_r) = x; return true; - - address: 0x20 - value_type: S_QWORD_R - read_lambda: return id(stored_s_qword_r); - write_lambda: id(stored_s_qword_r) = x; return true; - - address: 0x25 - value_type: FP32 - read_lambda: return id(stored_fp32); - write_lambda: id(stored_fp32) = x; return true; - - address: 0x28 - value_type: FP32_R - read_lambda: return id(stored_fp32_r); - write_lambda: id(stored_fp32_r) = x; return true; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - min_value: -16777215 - max_value: 16777215 - step: 0.01 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - min_value: -16777215 - max_value: 16777215 - step: 0.01 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 232e1fb654..36aa9a9668 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -19,23 +19,40 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest -from .state_utils import SensorTracker, find_entity, wait_for_state +from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction -@dataclass -class RegisterTestCase: - """Test parameters for a single modbus register write/read round-trip.""" +def _swap16(value: int) -> int: + """Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" + return ((value & 0xFF) << 8) | (value >> 8) - initial_value: object - write_number_name: str - write_value: float - post_write_value: object + +# Raw U_WORD view of reg_u_word_s's initial 0x1234 +MESH_RAW_U_WORD_S = _swap16(4660) + +# Initial values of the mesh fixture's address 1 registers; the +# server_controller test reads them and the write test uses them as baseline. +MESH_INITIAL_VALUES: dict[str, object] = { + "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_s_word": -99, + "reg_s_word_s": -2, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(2.5), +} # --------------------------------------------------------------------------- @@ -310,23 +327,7 @@ async def test_uart_mock_modbus_server_controller( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = { - "reg_u_word": 99, - "reg_u_word_s": 4660, - "reg_u_word_s_raw": 13330, - "reg_s_word": -99, - "reg_s_word_s": -2, - "reg_u_dword": 16909060, - "reg_s_dword": -16909060, - "reg_u_dword_r": pytest.approx(67305985), - "reg_s_dword_r": pytest.approx(-67305985), - "reg_u_qword": pytest.approx(72623859790382856), - "reg_s_qword": pytest.approx(-72623859790382856), - "reg_u_qword_r": pytest.approx(578437695752307201), - "reg_s_qword_r": pytest.approx(-578437695752307201), - "reg_fp32": pytest.approx(3.14), - "reg_fp32_r": pytest.approx(3.14), - } + expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -334,14 +335,12 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_write( yaml_config: str, @@ -357,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - register_test_cases: dict[str, RegisterTestCase] = { - "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), - "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), - "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), - "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), - "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), - "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), - "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), - "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), - "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), - "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), - "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), - "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), - "reg_fp32": RegisterTestCase( - pytest.approx(1.5, abs=0.01), - "write_fp32", - 3.14, - pytest.approx(3.14, abs=0.01), - ), - "reg_fp32_r": RegisterTestCase( - pytest.approx(2.5, abs=0.01), - "write_fp32_r", - 6.28, - pytest.approx(6.28, abs=0.01), - ), + # Per read-back sensor: the number entity to write through and the value; + # floats read back within tolerance, everything else exactly + register_writes: dict[str, tuple[str, int | float]] = { + "reg_u_word": ("write_u_word", 42), + "reg_u_word_s": ("write_u_word_s", 17185), + "reg_s_word": ("write_s_word", -42), + "reg_s_word_s": ("write_s_word_s", -257), + "reg_u_dword": ("write_u_dword", 2002), + "reg_s_dword": ("write_s_dword", -2002), + "reg_u_dword_r": ("write_u_dword_r", 4004), + "reg_s_dword_r": ("write_s_dword_r", -4004), + "reg_u_qword": ("write_u_qword", 6006), + "reg_s_qword": ("write_s_qword", -6006), + "reg_u_qword_r": ("write_u_qword_r", 8008), + "reg_s_qword_r": ("write_s_qword_r", -8008), + "reg_fp32": ("write_fp32", 6.28), + "reg_fp32_r": ("write_fp32_r", 9.42), } - tracker = SensorTracker(list(register_test_cases.keys())) + tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) + # The raw U_WORD view of 0x02 pins the byte swap on the write path: the + # round trip through write_u_word_s applies the swap an even number of + # times, so only the raw sensor can catch a symmetrically dropped swap. # Phase 1: expect initial baseline values initial_futures = tracker.expect_all( - {name: case.initial_value for name, case in register_test_cases.items()} + MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} ) # Phase 2: expect post-write values (registered now so on_state can match them) written_futures = tracker.expect_all( - {name: case.post_write_value for name, case in register_test_cases.items()} + { + name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value + for name, (_, value) in register_writes.items() + } + | {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])} ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the baseline can already be in the - # states the device sends on connect; matching it there saves waiting for - # the next poll entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -410,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write( # connection is working before issuing writes await tracker.await_all(initial_futures, timeout=4.0) - # Issue write commands for all register types - for case in register_test_cases.values(): - entity = find_entity(entities, case.write_number_name, NumberInfo) - assert entity is not None, ( - f"{case.write_number_name} number entity not found" - ) - client.number_command(entity.key, case.write_value) + # Issue write commands for all register types; exact object_id match, + # since several write_* names are prefixes of a sibling + numbers = { + e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) + } + for number_name, value in register_writes.values(): + entity = numbers.get(number_name) + assert entity is not None, f"{number_name} number entity not found" + client.number_command(entity.key, value) # Wait for sensors to reflect the written values (round-trip write+read) await tracker.await_all(written_futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_bits( yaml_config: str, @@ -468,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot and binary sensors drop repeats, so the - # baseline can arrive only in the states the device sends on connect entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -480,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits( # Flip both writable bits: 0x02 false -> true, 0x03 true -> false for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): - entity = find_entity(entities, switch_name, SwitchInfo) - assert entity is not None, f"{switch_name} switch entity not found" + entity = require_entity(entities, switch_name, SwitchInfo) client.switch_command(entity.key, value) # Wait for both read views to reflect the written values @@ -508,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 3fe87688d92d7272ecd969aa24b9ac7c42e2d841 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:31:27 -0500 Subject: [PATCH 171/316] [esp32] Enable octal flash when the flash mode is opi (#19218) --- esphome/components/esp32/__init__.py | 9 ++++++++ .../esp32/config/flash_mode_opi_s3.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index de25afa23b..0c5b9c5df6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1519,6 +1519,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -2732,6 +2739,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True diff --git a/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml new file mode 100644 index 0000000000..82262f6349 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 777759e8af..d5d0acfb2a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -252,6 +252,16 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "flash_mode": "opi", + "framework": {"type": "esp-idf"}, + }, + r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]", + id="flash_mode_opi_only_on_s3", + ), ], ) def test_esp32_configuration_errors( @@ -704,10 +714,22 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False assert CORE.platformio_options.get("board_build.flash_mode") == "qio" assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +def test_flash_mode_opi_enables_octal_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode.""" + generate_main(component_config_path("flash_mode_opi_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True + + def test_flash_mode_unset_leaves_defaults( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From ffb1ad288f29faed92e8591c38724ed5efba721e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:32:13 -0500 Subject: [PATCH 172/316] [remote_receiver] Wake the main loop when the RMT callback stores a frame (#19102) --- .../remote_receiver/remote_receiver_rmt.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 4eebbbb16f..e4ffd7e110 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -14,25 +15,32 @@ static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + return task_woken != pdFALSE; } void RemoteReceiverComponent::setup() { From b001514cf30f616d698f2839ffd3a005272d6b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:33:52 -0500 Subject: [PATCH 173/316] [remote_base] Accept protocols registered by external components (#19332) --- esphome/components/remote_base/__init__.py | 14 ++++-- .../receiver_with_external_protocol.yaml | 36 +++++++++++++++ .../fake_protocol/__init__.py | 39 ++++++++++++++++ .../remote_receiver/test_slot_counts.py | 46 ++++++++++++++++++- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml create mode 100644 tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 27b6eb9fc8..bf8707ff1e 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -169,6 +169,12 @@ def request_protocol(name: str) -> None: cg.add_define(protocol_define(name)) +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + # Only the protocol sources a configuration uses are compiled FILTER_SOURCE_FILES = filter_source_files_from_defines( {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} @@ -182,7 +188,7 @@ def register_binary_sensor( def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: async def new_func(var: MockObj, config: ConfigType) -> None: - request_protocol(name) + _request_protocol_if_in_tree(name) await coroutine(func)(var, config) return registerer(new_func) @@ -200,7 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -259,7 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: diff --git a/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml new file mode 100644 index 0000000000..e094e5bd52 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml @@ -0,0 +1,36 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +external_components: + - source: + type: local + path: ../external_components + +fake_protocol: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - fake + - nec + on_fake: + then: + - remote_transmitter.transmit_fake: + on_nec: + then: + - logger.log: nec + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +binary_sensor: + - platform: remote_receiver + name: Fake Input + fake: diff --git a/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py new file mode 100644 index 0000000000..971497aa79 --- /dev/null +++ b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py @@ -0,0 +1,39 @@ +"""External component registering a protocol that has no source file in remote_base.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.types import ConfigType + +DEPENDENCIES = ["remote_base"] + +ns = cg.esphome_ns.namespace("fake_protocol") +FakeData = ns.struct("FakeData") +FakeBinarySensor = ns.class_( + "FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase +) +FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger) +FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase) +FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase) + +CONFIG_SCHEMA = cv.Schema({}) + + +@remote_base.register_binary_sensor("fake", FakeBinarySensor, {}) +def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_trigger("fake", FakeTrigger, FakeData) +def fake_trigger(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_dumper("fake", FakeDumper) +def fake_dumper(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_action("fake", FakeAction, {}) +async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None: + pass diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index 4d69e6d923..ee79e9a06d 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -1,13 +1,16 @@ """Listener and dumper StaticVector sizes come from codegen slot counts.""" -from collections.abc import Callable +from collections.abc import Callable, Generator from pathlib import Path +import sys import pytest +from esphome import loader from esphome.automation import ACTION_REGISTRY from esphome.components import remote_base import esphome.config_validation as cv +from esphome.core import CORE from ..helpers import get_define_value @@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None: assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name +@pytest.fixture +def restore_protocol_registries() -> Generator[None]: + """Loading an external protocol component adds to module-level registries; undo that. + + The loader caches the component too, so drop it or a second load would skip the + decorators and leave the restored registries without the external names. + """ + registries = ( + remote_base.BINARY_SENSOR_REGISTRY, + remote_base.TRIGGER_REGISTRY, + remote_base.DUMPER_REGISTRY, + ACTION_REGISTRY, + ) + saved = [dict(registry) for registry in registries] + yield + for registry, entries in zip(registries, saved, strict=True): + registry.clear() + registry.update(entries) + loader._COMPONENT_CACHE.pop("fake_protocol", None) + sys.modules.pop("esphome.components.fake_protocol", None) + + +@pytest.mark.usefixtures("restore_protocol_registries") +def test_external_protocols_register_without_a_remote_base_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An external protocol goes through all four decorators without a source file here, so no define is emitted.""" + main_cpp = generate_main( + component_config_path("receiver_with_external_protocol.yaml") + ) + defines = {define.name for define in CORE.defines} + assert "USE_REMOTE_PROTOCOL_NEC" in defines + assert "USE_REMOTE_PROTOCOL_FAKE" not in defines + for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"): + assert f"fake_protocol::{cls}" in main_cpp, cls + # fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3" + + def test_request_protocol_rejects_unknown_names() -> None: """A misspelled protocol would otherwise surface only as a link error.""" with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): From 8bcb5004da8af416f11028bf4609f2c78ca104e5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 19:35:07 -0400 Subject: [PATCH 174/316] [sendspin] Add a switch platform to enable and disable the client (#19361) --- CODEOWNERS | 1 + .../media_player/sendspin_media_player.cpp | 4 ++ .../media_source/sendspin_media_source.cpp | 17 ++++- esphome/components/sendspin/sendspin_hub.cpp | 67 +++++++++++++------ esphome/components/sendspin/sendspin_hub.h | 28 ++++++-- .../components/sendspin/switch/__init__.py | 31 +++++++++ .../sendspin/switch/sendspin_switch.cpp | 26 +++++++ .../sendspin/switch/sendspin_switch.h | 24 +++++++ esphome/core/defines.h | 1 + tests/components/sendspin/common-switch.yaml | 6 ++ .../sendspin/test-switch.esp32-idf.yaml | 2 + 11 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 esphome/components/sendspin/switch/__init__.py create mode 100644 esphome/components/sendspin/switch/sendspin_switch.cpp create mode 100644 esphome/components/sendspin/switch/sendspin_switch.h create mode 100644 tests/components/sendspin/common-switch.yaml create mode 100644 tests/components/sendspin/test-switch.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7f44f8323c..aba498c365 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -478,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f42..59ead1bb53 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e83..c3fb1fe1cb 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index ca443c1840..58ec57c768 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,10 +21,6 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; -#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE -static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; -#endif - #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -66,26 +62,24 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start()) { - ESP_LOGE(TAG, "Failed to start Sendspin client"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } this->client_->loop(); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE - // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, - // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. - if (!this->mdns_advertised_ && this->mdns_->is_ready()) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { - this->mdns_enable_attempt_ms_ = now; - this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); - } - } + this->update_mdns_service_(); #endif } @@ -114,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -251,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index daaeb2c9a5..b00fdc436e 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -97,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -105,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -115,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -130,6 +130,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -212,6 +223,11 @@ class SendspinHub final : public Component, /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -290,6 +306,9 @@ class SendspinHub final : public Component, bool task_stack_in_psram_{false}; + // Requested client state, applied from loop(). Empty until the switch restores its state. + std::optional enabled_; + // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. const char *manufacturer_{"ESPHome"}; @@ -298,8 +317,7 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - uint32_t mdns_enable_attempt_ms_{0}; - bool mdns_advertised_{false}; + bool mdns_advertised_{false}; // Last state requested from mdns #endif }; diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 0000000000..63f5f7ad28 --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +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.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 0000000000..0bf029d4c7 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 0000000000..253d952b22 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b2b5267b11..bc1418c6e2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -400,6 +400,7 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml new file mode 100644 index 0000000000..d332cb0dde --- /dev/null +++ b/tests/components/sendspin/common-switch.yaml @@ -0,0 +1,6 @@ +packages: + sendspin: !include common.yaml + +switch: + - platform: sendspin + name: "Sendspin Enabled" diff --git a/tests/components/sendspin/test-switch.esp32-idf.yaml b/tests/components/sendspin/test-switch.esp32-idf.yaml new file mode 100644 index 0000000000..d32c14c054 --- /dev/null +++ b/tests/components/sendspin/test-switch.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-switch.yaml From 1a04359454be71e184b21b6227660b9b476145f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 19:04:14 -0500 Subject: [PATCH 175/316] [switch] Use Switch::control() instead of hand written turn_on/turn_off branches (#19364) --- esphome/components/api/api_connection.cpp | 7 +----- .../components/copy/switch/copy_switch.cpp | 8 +------ .../components/gpio/switch/gpio_switch.cpp | 12 ++-------- esphome/components/ld6002b/ld6002b.cpp | 20 ++++------------ .../switch/modbus_switch.cpp | 6 +---- .../output/switch/output_switch.cpp | 10 +------- esphome/components/sprinkler/sprinkler.cpp | 24 ++++--------------- esphome/components/switch/switch.cpp | 1 - .../template/switch/template_switch.cpp | 6 +---- 9 files changed, 15 insertions(+), 79 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 749eaeb392..3064ff09b1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -720,12 +720,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0..555f0030a5 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a..d231b3d77a 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df331..aa34ad9d39 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index f2aae201f3..855a7b28c3 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,11 +16,7 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a8639..325514ddf4 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,15 +6,7 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { if (state) { this->output_->turn_on(); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208b..cdec158126 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 8413c7b493..57e4f222bc 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index edd753d3d2..729db37053 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -42,11 +42,7 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { From 34158e17b885bcc749fac6003b2be665a3a2765f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 21:42:29 -0400 Subject: [PATCH 176/316] [mixer] Raise ducking decibel_reduction maximum to 255 (#19347) --- esphome/components/mixer/speaker/__init__.py | 2 +- tests/components/mixer/common.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3746c019a..26619f35a7 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -155,7 +155,7 @@ async def to_code(config: ConfigType) -> None: { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index 55e96df4c2..489475c794 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -3,7 +3,7 @@ esphome: then: - mixer_speaker.apply_ducking: id: source_speaker_1_id - decibel_reduction: 10 + decibel_reduction: 255 duration: 1s speaker: From 4be83021904a0f028124d500c76bba8bad1bd412 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:36:08 +1200 Subject: [PATCH 177/316] [ci] Move skills to .agents/skills with .claude and .github symlinks (#19369) --- {.claude => .agents}/skills/pr-workflow/SKILL.md | 0 .claude/skills | 1 + .github/skills | 1 + script/ci-custom.py | 3 +++ 4 files changed, 5 insertions(+) rename {.claude => .agents}/skills/pr-workflow/SKILL.md (100%) create mode 120000 .claude/skills create mode 120000 .github/skills diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/skills b/.github/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/script/ci-custom.py b/script/ci-custom.py index 2c9a64c68b..286dda85b9 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -247,6 +247,9 @@ def lint_ext_check(fname): "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", + # Symlinks to the shared .agents/skills directory + ".claude/skills", + ".github/skills", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] From cf0a87de28cd74b2171964c32c113099ed2544a3 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Thu, 17 Sep 2026 04:47:40 -0500 Subject: [PATCH 178/316] [mixer] Don't discard a start request while reaping a stopped task (#19368) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 7d33b6c49f..41b7123269 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -385,7 +385,8 @@ void MixerSpeaker::loop() { // Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } From 8cd22eaceae171c9c01e297424e2532759af3ad9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 17 Sep 2026 08:23:55 -0400 Subject: [PATCH 179/316] [audio] Bump esp-audio-libs to v4.0.1 (#19372) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 14a0818894..b882aaa6b7 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,7 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 95337007b8..d12a27221b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 4.0.0 + version: 4.0.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 7e3d4a48d156f100b0bcb9bd45a801a321f45568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 07:56:29 -0500 Subject: [PATCH 180/316] [modbus_controller] Remove deprecated helper shims (#19076) --- .../modbus_controller/modbus_controller.h | 74 +------------------ 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 741d4f6f00..d21b319435 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; #pragma GCC diagnostic pop -// Remove before 2026.10.0 — these helpers have moved to modbus::helpers -ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") -inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } - -ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_read_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_write_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } - -ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::byte_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::word_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::dword_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::qword_from_hex_str(value, pos); -} - -template -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(data, buffer_offset); -} - -// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a // std::span (previously a const std::vector &), and a span does not convert to // a vector, so existing lambdas calling these by name need an overload that accepts one. These carry -// this release's deprecation window, since the span forms only exist from it. +// the 2026.8.0 deprecation window, since the span forms only exist from it. // payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers // type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here // would only make that call ambiguous. @@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span data) { return modbus::helpers::bit_from_packed(coil, data); } -template -ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") -N mask_and_shift_by_rightbit(N data, uint32_t mask) { - return modbus::helpers::mask_and_shift_by_rightbit(data, mask); -} - -ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - modbus::helpers::number_to_payload(data, value, value_type); -} - -ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") -inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) - .value_or(0); -} - -ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline std::vector float_to_payload(float value, SensorValueType value_type) { - std::vector data; - modbus::helpers::float_to_payload(data, value, value_type); - return data; -} - -class ModbusController; - /// How an item relates to the register range built just before it (same register type, address order). /// The numeric order doubles as the comparator tiebreak for items at the same address (see /// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and From 942322738a4d2d15ecee759e31ac90d1ff447fb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:14:57 -0500 Subject: [PATCH 181/316] [remote_receiver] [remote_transmitter] Keep the RMT setup error message as a pointer to the literal (#19210) --- .../remote_receiver/remote_receiver.h | 4 +- .../remote_receiver/remote_receiver_rmt.cpp | 39 ++++++------------- .../remote_transmitter/remote_transmitter.h | 4 +- .../remote_transmitter_rmt.cpp | 35 ++++++----------- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index e59a8b2557..997863f032 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, protected: #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void decode_rmt_(rmt_symbol_word_t *item, size_t item_count); + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; bool with_dma_{false}; uint32_t carrier_frequency_{0}; uint8_t carrier_duty_percent_{100}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index e4ffd7e110..64392aa7ee 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -43,6 +43,11 @@ static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_r return task_woken != pdFALSE; } +void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteReceiverComponent::setup() { rmt_rx_channel_config_t channel; memset(&channel, 0, sizeof(channel)); @@ -55,13 +60,8 @@ void RemoteReceiverComponent::setup() { channel.flags.with_dma = this->with_dma_; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_rx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel")); return; } if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -71,9 +71,7 @@ void RemoteReceiverComponent::setup() { } error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } @@ -85,9 +83,7 @@ void RemoteReceiverComponent::setup() { carrier.flags.polarity_active_low = this->pin_->is_inverted(); error = rmt_apply_carrier(this->channel_, &carrier); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -97,9 +93,7 @@ void RemoteReceiverComponent::setup() { callbacks.on_recv_done = rmt_callback; error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_rx_register_event_callbacks"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks")); return; } @@ -122,9 +116,7 @@ void RemoteReceiverComponent::setup() { error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_receive"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_receive")); return; } } @@ -148,18 +140,11 @@ void RemoteReceiverComponent::dump_config() { (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteReceiverComponent::loop() { if (this->store_.error != ESP_OK) { - ESP_LOGE(TAG, "Receive error"); - this->error_code_ = this->store_.error; - this->error_string_ = "in rmt_callback"; - this->mark_failed(); + this->fail_(this->store_.error, LOG_STR("in rmt_callback")); } if (this->store_.overflow) { ESP_LOGW(TAG, "Buffer overflow"); diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60..99e1ce9504 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); void configure_rmt_(); void wait_for_rmt_(); @@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472..6d27be8d47 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size } #endif +void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() { if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) { ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_); } - - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteTransmitterComponent::digital_write(bool value) { @@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() { #endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_tx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel")); return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() { encoder.min_chunk_size = 1; error = rmt_new_simple_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_simple_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_simple_encoder")); return; } #else @@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() { memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_copy_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_copy_encoder")); return; } #endif error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } this->digital_write(open_drain || this->inverted_); @@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() { error = rmt_apply_carrier(this->channel_, &carrier); } if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } From a7559547b27f2f3179071ab782d990fe1583d9ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:15:13 -0500 Subject: [PATCH 182/316] [remote_receiver] Treat buffer size as bytes on the pulse ring targets (#19101) --- .../components/remote_receiver/__init__.py | 15 ++++---- .../remote_receiver/remote_receiver.cpp | 15 ++++---- .../remote_receiver/remote_receiver.h | 2 +- .../config/receiver_bk72xx.yaml | 9 +++++ .../config/receiver_esp32_c61.yaml | 12 ++++++ .../config/receiver_ln882x.yaml | 9 +++++ .../remote_receiver/config/receiver_rp2.yaml | 9 +++++ .../config/receiver_rtl87xx.yaml | 9 +++++ .../remote_receiver/test_buffer_size.py | 38 ++++++++++++++----- 9 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_ln882x.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rp2.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6eaecf7ab0..866e108131 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -112,20 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers, cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, + # pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses cv.SplitDefault( CONF_BUFFER_SIZE, esp32=cv.UNDEFINED, # the pulse ring needs a size; only RMT targets size themselves in setup() **{ - f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b" + f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b" for variant in esp32_rmt.VARIANTS_NO_RMT }, - esp8266="1000b", - bk72xx="1000b", - ln882x="1000b", - rtl87xx="1000b", - rp2="1000b", - ): cv.All(cv.validate_bytes, cv.int_range(min=64)), + esp8266="4000b", + bk72xx="4000b", + ln882x="4000b", + rtl87xx="4000b", + rp2="4000b", + ): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765..b3e4649096 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_ int32_t multiplier = ((int32_t) level << 1) - 1; uint32_t buffer_write = arg->buffer_write; arg->buffer[buffer_write++] = (int32_t) delta * multiplier; - if (buffer_write >= arg->buffer_size) { + if (buffer_write >= arg->buffer_entries) { buffer_write = 0; } @@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() { this->store_.idle_us = this->idle_us_; this->store_.filter_us = this->filter_us_; this->store_.pin = this->pin_->to_isr(); - this->store_.buffer = new int32_t[this->buffer_size_]; - this->store_.buffer_size = this->buffer_size_; + // rounded up so a size that is not a multiple of four never holds less than requested + this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t); + this->store_.buffer = new int32_t[this->store_.buffer_entries]; this->store_.prev_micros = micros(); this->store_.commit_micros = this->store_.prev_micros; this->store_.prev_level = this->pin_->digital_read(); @@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG( TAG, "Remote Receiver:\n" - " Buffer Size: %" PRIu32 "\n" + " Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n" " Tolerance: %" PRIu32 "%s\n" " Filter out pulses shorter than: %" PRIu32 " us\n" " Signal is done after %" PRIu32 " us of no changes", - this->buffer_size_, this->tolerance_, + this->buffer_size_, this->store_.buffer_entries, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); @@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() { while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { reserve_size++; temp_read++; - if (temp_read >= s.buffer_size) { + if (temp_read >= s.buffer_entries) { temp_read = 0; } } @@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() { // read the buffer for (uint32_t i = 0; i < reserve_size + 1; i++) { this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); - if (s.buffer_read >= s.buffer_size) { + if (s.buffer_read >= s.buffer_entries) { s.buffer_read = 0; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 997863f032..6f93979b18 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore { uint32_t buffer_read{0}; volatile uint32_t commit_micros{0}; volatile uint32_t prev_micros{0}; - uint32_t buffer_size{1000}; + uint32_t buffer_entries{0}; uint32_t filter_us{10}; uint32_t idle_us{10000}; ISRInternalGPIOPin pin; diff --git a/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml new file mode 100644 index 0000000000..c9c95ed05c --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7252 + +remote_receiver: + - id: rcvr + pin: P6 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml new file mode 100644 index 0000000000..e8930d4e17 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c61-devkitc1 + variant: esp32c61 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml new file mode 100644 index 0000000000..8767b546e6 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h + +remote_receiver: + - id: rcvr + pin: PA4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rp2.yaml b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml new file mode 100644 index 0000000000..cfc66786ba --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rp2: + board: rpipicow + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml new file mode 100644 index 0000000000..113bece34c --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +remote_receiver: + - id: rcvr + pin: PA12 diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py index 9bfd12d9f5..cc4ea49ccb 100644 --- a/tests/component_tests/remote_receiver/test_buffer_size.py +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -1,8 +1,16 @@ -"""buffer_size reaches the receiver when set, and always on the pulse ring targets.""" +"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set.""" from collections.abc import Callable from pathlib import Path +import pytest + +from esphome.components import remote_receiver +from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + def test_explicit_buffer_size_is_passed_through( generate_main: Callable[[str | Path], str], @@ -12,17 +20,29 @@ def test_explicit_buffer_size_is_passed_through( assert "rcvr->set_buffer_size(2000);" in main_cpp -def test_pulse_ring_target_keeps_a_default( +@pytest.mark.parametrize( + "target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"] +) +def test_pulse_ring_default_holds_1000_pulses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], + target: str, ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp8266.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml")) + assert "rcvr->set_buffer_size(4000);" in main_cpp -def test_esp32_variant_without_rmt_keeps_a_default( - generate_main: Callable[[str | Path], str], - component_config_path: Callable[[str], Path], +@pytest.mark.parametrize( + ("value", "expected"), + [("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)], +) +def test_buffer_size_range( + set_core_config: SetCoreConfigCallable, value: str, expected: int | None ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = {"pin": "GPIO4", "buffer_size": value} + if expected is None: + with pytest.raises(Invalid): + remote_receiver.CONFIG_SCHEMA(config) + else: + assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected From 2a8d6b69cd0dcbe497e1ed4b8576a3a0710cb4a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:16:41 -0500 Subject: [PATCH 183/316] [rp2] Bump arduino-pico framework to 6.1.0 (#19261) --- .../bluetooth_connection_rp2.cpp | 2 +- esphome/components/rp2/__init__.py | 12 ++++++------ esphome/components/rp2/boards.py | 18 ++++++++++++++++++ .../components/rp2040_ble/btstack_memory.cpp | 2 +- esphome/core/defines.h | 2 +- platformio.ini | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd..eec2c8c318 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { // explicit kick the MTU would only be exchanged on the first GATT query, // which never happens on a V3_WITH_CACHE connection. // Both registration calls above return void (BTstack 075a078, arduino-pico - // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by // the connect timeout in loop(). gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); } diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index dae7df26c3..a1bbf6a3d6 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,20 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain -# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" +# develop-branch commit carrying the arduino-pico 6.1.0 update and the board +# JSON files it adds; replace with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 0, 0), None), + "dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b0..a9ce11c33d 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "soldered_nula_node_rp2040": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -2127,6 +2139,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_node_rp2040": { + "name": "Soldered Electronics NULA Node", + "mcu": "rp2040", + "max_pin": 29, + "wifi": True, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 8af57924a2..699555f623 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bc1418c6e2..fb76f90bcf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -537,7 +537,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/platformio.ini b/platformio.ini index 722109adec..37504384cb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -203,11 +203,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 platform_packages = ; The framework-arduinopico package is no longer published to the PlatformIO ; registry, so install the framework straight from the GitHub release - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip framework = arduino lib_deps = From 508c24c5e3c6394a3bda02aa4711b3225bc5f57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:11 -0500 Subject: [PATCH 184/316] [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) --- esphome/components/esp32/crash_handler.cpp | 14 +++++++++----- esphome/components/esp32/crash_handler.h | 7 +------ esphome/components/esp32/hal.cpp | 8 -------- esphome/components/rp2/crash_handler.cpp | 22 +++++++++++++++++----- esphome/components/rp2/crash_handler.h | 3 ++- esphome/core/application.h | 4 ++-- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa..b72a2777c7 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ec..314be80314 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f..199cb89f51 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea21637..9bcdc8bee4 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b..3aec80b63b 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/core/application.h b/esphome/core/application.h index f1cf6fcca0..8ed4c09096 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); From 05bba3fdc262a01b5e5932f39e0a7cd52d696d4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:28 -0500 Subject: [PATCH 185/316] [image] Blend grayscale alpha with Color::gradient (#19356) --- esphome/components/image/image.cpp | 12 +++++------- esphome/core/color.cpp | 15 +++++---------- esphome/core/color.h | 12 +++++++++--- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683ab..bfe311be28 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color continue; // skip drawing } break; - case TRANSPARENCY_ALPHA_CHANNEL: { - auto on = (float) gray / 255.0f; - auto off = 1.0f - on; - // blend color_on and color_off - color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off, - color_on.b * on + color_off.b * off, 0xFF); + case TRANSPARENCY_ALPHA_CHANNEL: + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); break; - } default: break; } diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc771472..ba8a594340 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,18 +6,13 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); -Color Color::gradient(const Color &to_color, uint8_t amnt) { - uint8_t inv = 255 - amnt; - Color new_color; - new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255; - new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255; - new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255; - new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255; - return new_color; +Color Color::gradient(const Color &to_color, uint8_t amnt) const { + return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt), + blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt)); } -Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } +Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); } -Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } +Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); } } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623d..c7fd522e1a 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,9 +174,15 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt); - Color fade_to_white(uint8_t amnt); - Color fade_to_black(uint8_t amnt); + /// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a + /// per pixel loop can blend without a call; gradient() itself stays out of + /// line so the light effects and fade_to_*() share one copy. + static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE { + return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255; + } + Color gradient(const Color &to_color, uint8_t amnt) const; + Color fade_to_white(uint8_t amnt) const; + Color fade_to_black(uint8_t amnt) const; Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } From 40934484c6d589c5ae29a980f00aaf9a95ae59cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:34:36 -0500 Subject: [PATCH 186/316] [output] Use BinaryOutput::set_state() instead of hand written turn_on/turn_off branches (#19366) --- esphome/components/binary/light/binary_light_output.h | 6 +----- esphome/components/improv_ble/improv_ble_component.cpp | 6 +----- esphome/components/mcp4461/output/mcp4461_output.cpp | 8 -------- esphome/components/mcp4461/output/mcp4461_output.h | 3 --- esphome/components/output/switch/output_switch.cpp | 6 +----- 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c..b8de7932cd 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput { void write_state(light::LightState *state) override { bool binary; state->current_values_as_binary(&binary); - if (binary) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(binary); } protected: diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index bbc1589abf..0a20beb33c 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -208,11 +208,7 @@ void ImprovBLEComponent::set_status_indicator_state_(bool state) { if (this->status_indicator_state_ == state) return; this->status_indicator_state_ = state; - if (state) { - this->status_indicator_->turn_on(); - } else { - this->status_indicator_->turn_off(); - } + this->status_indicator_->set_state(state); #endif } diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d..d38eed4d09 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() { return this->state_; } -void Mcp4461Wiper::set_state(bool state) { - if (state) { - this->turn_on(); - } else { - this->turn_off(); - } -} - void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); } void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec5..1052369a74 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedcontrol(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { - if (state) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(state); this->publish_state(state); } From 28b2a689a722313715664936fd7fcc3424f7e29f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:02 -0500 Subject: [PATCH 187/316] [web_server] Reduce flash used by the JSON and request helpers (#19303) --- esphome/components/json/json_util.cpp | 2 + esphome/components/json/json_util.h | 3 + esphome/components/web_server/web_server.cpp | 68 +++++++++++-------- esphome/components/web_server/web_server.h | 2 +- .../web_server_idf/web_server_idf.h | 4 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f..1b1eefe59b 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() = default; + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b..130e150332 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) { /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); + JsonObject root() { if (!root_created_) { root_ = doc_.to(); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1683492da7..8b0dfe166f 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -66,9 +66,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } + json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); @@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } @@ -382,7 +390,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } @@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d60b39278a..7aa4ac24a3 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c564..743d296d73 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; public: - AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. From 271f85185d5138ed48ef0d113ab7676ae7e30e68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:15 -0500 Subject: [PATCH 188/316] [sensor] Remove deprecated raw_state member (#19077) --- .../number/modbus_number.cpp | 1 - .../sensor/modbus_sensor.cpp | 1 - esphome/components/sensor/sensor.cpp | 10 +- esphome/components/sensor/sensor.h | 24 ++-- .../fixtures/sensor_raw_state.yaml | 53 +++++++++ .../fixtures/sensor_raw_state_no_filter.yaml | 31 +++++ tests/integration/test_sensor_raw_state.py | 108 ++++++++++++++++++ 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 tests/integration/fixtures/sensor_raw_state.yaml create mode 100644 tests/integration/fixtures/sensor_raw_state_no_filter.yaml create mode 100644 tests/integration/test_sensor_raw_state.py diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517..223aa12bec 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd0..2035f2220a 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b..bee5d7c6d3 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(state_class), 0); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -Sensor::Sensor() : state(NAN), raw_state(NAN) {} -#pragma GCC diagnostic pop +Sensor::Sensor() : state(NAN) {} int8_t Sensor::get_accuracy_decimals() { if (this->sensor_flags_.has_accuracy_override) @@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() { } void Sensor::publish_state(float state) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->raw_state = state; -#pragma GCC diagnostic pop #ifdef USE_SENSOR_FILTER + this->raw_state_ = state; this->raw_callback_.call(state); #endif diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af985..20288fa88e 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -96,18 +96,20 @@ class Sensor : public EntityBase { /// Getter-syntax for .state. float get_state() const { return this->state; } - /// Getter-syntax for .raw_state + /// Get the last state received by publish_state(), before any filters were applied. float get_raw_state() const { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop +#ifdef USE_SENSOR_FILTER + return this->raw_state_; +#else + return this->state; // No filters compiled in, raw == filtered +#endif } /** Publish a new state to the front-end. * - * First, the new state will be assigned to the raw_value. Then it's passed through all filters - * until it finally lands in the .value member variable and a callback is issued. + * The value is passed through the filter chain (when filters are compiled in) before landing in + * the `state` member and triggering the state callback. The pre-filter value is available via + * get_raw_state(). * * @param state The state as a floating point number. */ @@ -137,17 +139,11 @@ class Sensor : public EntityBase { */ float state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0") - float raw_state; -#pragma GCC diagnostic pop - void internal_send_state_to_frontend(float state); protected: #ifdef USE_SENSOR_FILTER + float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/tests/integration/fixtures/sensor_raw_state.yaml b/tests/integration/fixtures/sensor_raw_state.yaml new file mode 100644 index 0000000000..9c19032028 --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state.yaml @@ -0,0 +1,53 @@ +esphome: + name: test-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists +sensor: + # No filters on this sensor: get_raw_state() must equal state + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + + # Filtered sensor: get_raw_state() must be the pre-filter value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + accuracy_decimals: 1 + filters: + - multiply: 2.0 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - sensor.template.publish: + id: with_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "WITH_FILTER: state=%.1f raw_state=%.1f" + args: + - id(with_filter_sensor).state + - id(with_filter_sensor).get_raw_state() diff --git a/tests/integration/fixtures/sensor_raw_state_no_filter.yaml b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml new file mode 100644 index 0000000000..fec912691f --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml @@ -0,0 +1,31 @@ +esphome: + name: test-sensor-raw-state-no-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and +# get_raw_state() falls back to state +sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py new file mode 100644 index 0000000000..a178ebf7d4 --- /dev/null +++ b/tests/integration/test_sensor_raw_state.py @@ -0,0 +1,108 @@ +"""Integration tests for Sensor::get_raw_state(). + +Raw state storage only exists when filters are compiled in (USE_SENSOR_FILTER). +Without it, get_raw_state() returns state, so both build configurations are covered: +one fixture with a filtered sensor and one with no filters at all. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)") +WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)") + + +async def _press_and_read( + client: APIClient, + entities: list[EntityInfo], + button_object_id: str, + future: asyncio.Future[tuple[float, float]], + label: str, +) -> tuple[float, float]: + button = next( + (e for e in entities if button_object_id in e.object_id.lower()), None + ) + assert button is not None, f"{button_object_id} not found" + client.button_command(button.key) + try: + return await asyncio.wait_for(future, timeout=5.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {label} log message") + + +@pytest.mark.asyncio +async def test_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With filters compiled in, raw state is stored separately from state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + if not with_filter_future.done() and ( + match := WITH_FILTER_PATTERN.search(line) + ): + with_filter_future.set_result( + (float(match.group(1)), float(match.group(2))) + ) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 + + state, raw_state = await _press_and_read( + client, + entities, + "test_with_filter_button", + with_filter_future, + "WITH_FILTER", + ) + assert state == 43.0 + assert raw_state == 21.5 + + +@pytest.mark.asyncio +async def test_sensor_raw_state_no_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Without filters compiled in, get_raw_state() returns state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 From 7d17efc15f1c41792af68f495077c7277f995e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:46 -0500 Subject: [PATCH 189/316] [espidf] Emit json2 size data so the link edge is not blocked (#18848) --- esphome/build_gen/espidf.py | 13 +- esphome/espidf/size_summary.py | 101 ++++++-- esphome/espidf/toolchain.py | 15 +- tests/unit_tests/build_gen/test_espidf.py | 12 + tests/unit_tests/test_espidf_toolchain.py | 37 +++ tests/unit_tests/test_size_summary.py | 298 +++++++++++++++++----- 6 files changed, 388 insertions(+), 88 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 2ef89cf595..7689fc93b0 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -90,9 +90,10 @@ def get_project_cmakelists( """ idf_target = variant_to_idf_target(get_esp32_variant()) - # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and - # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get - # --format=raw because the legacy mode doesn't support it. + # esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng; + # 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks + # total_size, hence the ELF fallback in espidf/size_summary.py; both + # go away together when 1.x support is dropped. size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" # Project-wide compile options: -D defines and -W warning flags (skip @@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) -# Emit raw JSON size data for ESPHome to read post-build. +# Emit per-memory-type JSON size data for ESPHome to read post-build. +# json2 stays small; raw dumps every symbol (~2s on a large map) and +# this command runs inside the link edge, blocking everything downstream. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 2be3634c69..d98363dd67 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -9,16 +9,19 @@ byte-identical to PlatformIO's output: Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) The format matches ``script/ci_memory_impact_extract.py`` so CI memory -analysis works unchanged on native ESP-IDF builds. RAM total is the -DRAM region size from the linker map; Flash total is taken from +analysis works unchanged on native ESP-IDF builds. RAM usage comes from +the DRAM (or unified DIRAM) region of the linker map. Flash used is the +exact image size matching the ``Total image size`` line: json2 +``total_size`` when present, otherwise derived from the ELF (see +``_image_size_from_elf``). Flash total is taken from ``partitions.csv`` using PlatformIO's rule (first app partition whose subtype is ``factory`` or ``ota_0``; see ``platform-espressif32/builder/main.py::_update_max_upload_size``). Structured size data is produced at link time by a CMake POST_BUILD custom command (see ``build_gen/espidf.py``) which writes -``esp_idf_size.json`` next to the ELF. We read that file here rather -than re-running ``esp_idf_size`` from Python. +``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary) +next to the ELF; we read that rather than re-running ``esp_idf_size``. """ from __future__ import annotations @@ -27,6 +30,7 @@ import csv import json import logging from pathlib import Path +import struct from esphome.build_helpers.size_summary import print_size_line @@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int: raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") -def print_summary(size_json: Path, partitions_csv: Path | None) -> None: +def _image_size_from_elf(elf: Path) -> int: + """Sum the allocated PROGBITS section sizes from an ELF32 file. + + Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte; + esptool's ``ELFFile`` filters sections differently and would not. + Raises ``ValueError`` for anything but a well-formed ELF32 LE file. + """ + with elf.open("rb") as f: + header = f.read(52) # ELF32 header + if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(f"{elf} is not a 32-bit little-endian ELF") + (e_shoff,) = struct.unpack_from(" None: """Print PlatformIO-shaped RAM and Flash one-liners. Failures are non-fatal: the build has already succeeded, we just couldn't - summarize. Logs the cause at debug level. + summarize. Anomalies (missing region, unreadable ELF) warn; expected + optional inputs (no size json, no partitions.csv) log at debug. """ if not size_json.is_file(): _LOGGER.debug("Skipping size summary: %s not found", size_json) @@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return - - memory_types = data.get("memory_types", {}) - ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} - ram_used = ram_region.get("used") - ram_total = ram_region.get("size") - if ram_total and ram_used is not None: - print_size_line("RAM", ram_used, ram_total) - - image_size = data.get("image_size") - if image_size is None or partitions_csv is None: + if not isinstance(data, dict): + _LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json) return + + layout = data.get("layout") + regions = { + entry.get("name"): entry + for entry in (layout if isinstance(layout, list) else []) + if isinstance(entry, dict) + } + # Every chip has a DRAM or DIRAM region, so a warning here usually + # means the esp_idf_size json schema changed + ram_region = regions.get("DRAM") or regions.get("DIRAM") + if ram_region is None: + _LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json) + elif ( + isinstance(ram_total := ram_region.get("total"), int) + and ram_total > 0 + and isinstance(ram_used := ram_region.get("used"), int) + ): + print_size_line("RAM", ram_used, ram_total) + else: + _LOGGER.warning( + "Skipping RAM summary: unusable region %s in %s", ram_region, size_json + ) + + # esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in + # json2; older 1.x omits it, so derive the same figure from the ELF. + flash_used = data.get("total_size") + if not (isinstance(flash_used, int) and flash_used > 0): + _LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf) + try: + flash_used = _image_size_from_elf(firmware_elf) + except (OSError, ValueError) as e: + # The ELF must be present and well formed after a successful build + _LOGGER.warning("Skipping Flash summary: %s", e) + return try: app_size = _find_app_partition_size(partitions_csv) - except ValueError as e: + except (OSError, ValueError) as e: _LOGGER.debug("Skipping Flash summary: %s", e) return - print_size_line("Flash", image_size, app_size) + if app_size <= 0: + _LOGGER.debug("Skipping Flash summary: app partition size is 0") + return + print_size_line("Flash", flash_used, app_size) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 986f9dfb8b..f695bdb7ab 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int: if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") - print_summary(size_json, partitions if partitions.is_file() else None) + print_summary(size_json, partitions, get_built_elf_path()) return rc @@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path: return build_dir / "firmware.ota.bin" +def get_built_elf_path() -> Path: + """Path to the ELF idf.py writes directly, ``/.elf``. + + Exists as soon as the build finishes, unlike the ``firmware.elf`` + copy that ``create_elf_copy`` makes later. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.elf" + + def get_elf_path() -> Path: """Get the path to the firmware ELF file. @@ -706,8 +716,7 @@ def create_elf_copy() -> bool: "download ELF" link requests the literal filename ``firmware.elf`` (PlatformIO convention), so copy it to that name. """ - build_dir = CORE.relative_build_path("build") - src_elf = build_dir / f"{CORE.name}.elf" + src_elf = get_built_elf_path() dst_elf = get_elf_path() if not src_elf.is_file(): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 079f10ddb9..2848d7202d 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -163,6 +163,18 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None: assert has_discovered_components() +def test_get_project_cmakelists_size_command_uses_json2() -> None: + """The POST_BUILD size command uses the cheap json2 format, with --ng + only on the 1.x tool bundled with IDF < 6.""" + content = _render() + assert "-m esp_idf_size --ng --format=json2" in content + + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0) + content = _render() + assert "--ng" not in content + assert "--format=json2" in content + + def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: """A cached list replaces project_description.json and is still filtered by EXCLUDE_COMPONENTS.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 9deb27d83c..bb2aab17a2 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -638,6 +638,43 @@ def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: mock_run.assert_called_once_with("build", "size", jobs=1) +def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: + """print_summary receives the size json, partitions.csv, and the built + ELF from get_built_elf_path, which must stay in lockstep with the + project() name in the generated CMakeLists.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary") as mock_summary, + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_summary.assert_called_once_with( + CORE.relative_build_path("build", "esp_idf_size.json"), + CORE.relative_build_path("partitions.csv"), + CORE.relative_build_path("build", f"{CORE.name}.elf"), + ) + + +def test_create_elf_copy(setup_core: Path) -> None: + """The built .elf is copied to the firmware.elf dashboard name.""" + _setup_build(setup_core) + src = toolchain.get_built_elf_path() + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(b"elf") + assert toolchain.create_elf_copy() is True + assert toolchain.get_elf_path().read_bytes() == b"elf" + + +def test_create_elf_copy_missing_source(setup_core: Path) -> None: + """A missing built ELF is a warning and False, not a crash.""" + _setup_build(setup_core) + assert toolchain.create_elf_copy() is False + + def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: """When no compile_process_limit is set, no job limit is passed to idf.py.""" _setup_build(setup_core) diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 0c0852a191..245184f2d0 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -4,6 +4,8 @@ from __future__ import annotations import json from pathlib import Path +import struct +from unittest.mock import patch import pytest @@ -17,64 +19,106 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path: return out +def _write_partitions(tmp_path: Path) -> Path: + """Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot.""" + out = tmp_path / "partitions.csv" + out.write_text( + "# name, type, subtype, offset, size, flags\n" + "app0, app, ota_0, 0x10000, 0x1C0000,\n" + ) + return out + + +def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes: + """Build a minimal ELF32 LE whose section headers carry the given + (sh_type, sh_flags, sh_size) triples.""" + out = bytearray(52) + out[0:4] = b"\x7fELF" + out[4] = out[5] = 1 # 32-bit, little-endian + struct.pack_into(" dict: - """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + """Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the + esp-idf-size >= 2.1 shape that carries ``total_size``.""" return { - "image_size": 827455, - "memory_types": { - "DRAM": { - "size": 180736, + "version": "1.1", + "total_size": 827455, + "layout": [ + { + "name": "DRAM", + "total": 180736, "used": 47332, - "sections": { - ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, - ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + "free": 133404, + "parts": { + ".bss": {"size": 30616}, + ".data": {"size": 16716}, }, }, - "IRAM": { - "size": 131072, + { + "name": "IRAM", + "total": 131072, "used": 80351, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 79323}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 50721, + "parts": { + ".text": {"size": 79323}, + ".vectors": {"size": 1028}, }, }, - }, + ], } def _s3_size_data() -> dict: - """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + """Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x + shape without ``total_size``.""" return { - "image_size": 724215, - "memory_types": { - "DIRAM": { - "size": 341760, + "version": "1.1", + "layout": [ + { + "name": "DIRAM", + "total": 341760, "used": 104999, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 58051}, - ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, - ".dram0.data": {"abbrev_name": ".data", "size": 19708}, - ".noinit": {"abbrev_name": ".noinit", "size": 152}, + "free": 236761, + "parts": { + ".text": {"size": 58051}, + ".bss": {"size": 27088}, + ".data": {"size": 19708}, + ".noinit": {"size": 152}, }, }, - "IRAM": { - "size": 16384, + { + "name": "IRAM", + "total": 16384, "used": 16384, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 15356}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 0, + "parts": { + ".text": {"size": 15356}, + ".vectors": {"size": 1028}, }, }, - }, + ], } +def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None: + """Call print_summary with no partitions.csv or ELF on disk.""" + print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf") + + def test_print_summary_esp32_uses_dram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + """Original ESP32: RAM = DRAM.used / DRAM.total.""" size_json = _write_size_json(tmp_path, _esp32_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" in out assert "used 47332 bytes from 180736 bytes" in out @@ -83,63 +127,193 @@ def test_print_summary_esp32_uses_dram( def test_print_summary_s3_falls_back_to_diram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + """ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage.""" size_json = _write_size_json(tmp_path, _s3_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "used 104999 bytes from 341760 bytes" in out def test_print_summary_skips_when_diram_total_collapses( - tmp_path: Path, capsys: pytest.CaptureFixture[str] + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A zero-size region drops the RAM line rather than divide by zero.""" size_json = _write_size_json( tmp_path, { - "memory_types": { - "DIRAM": { - "size": 0, - "used": 0, - "sections": {}, - }, - }, + "version": "1.1", + "layout": [{"name": "DIRAM", "total": 0, "used": 0}], }, ) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" not in out + assert "unusable region" in caplog.text def test_print_summary_handles_missing_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """Missing size json is non-fatal and prints nothing.""" - print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + _print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json") assert capsys.readouterr().out == "" -def test_print_summary_handles_no_memory_types( - tmp_path: Path, capsys: pytest.CaptureFixture[str] +def test_print_summary_handles_no_layout( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: - """A size json without ``memory_types`` still doesn't crash.""" - size_json = _write_size_json(tmp_path, {"image_size": 0}) - print_summary(size_json, partitions_csv=None) + """A size json without ``layout`` warns so schema drift is visible.""" + size_json = _write_size_json(tmp_path, {"version": "1.1"}) + _print_summary_ram_only(tmp_path, size_json) assert capsys.readouterr().out == "" - - -def test_print_summary_flash_line( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - """A partition table with an app row yields the Flash line in the exact - padded shape script/ci_memory_impact_extract.py greps.""" - size_json = _write_size_json(tmp_path, _esp32_size_data()) - partitions = tmp_path / "partitions.csv" - partitions.write_text( - "# name, type, subtype, offset, size, flags\n" - "app0, app, ota_0, 0x10000, 0x1C0000,\n" + assert any( + r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message + for r in caplog.records ) - print_summary(size_json, partitions) + + +def test_print_summary_flash_line_prefers_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """With ``total_size`` in the json, that figure wins without reading the + ELF, in the exact shape script/ci_memory_impact_extract.py greps.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + print_summary(size_json, partitions, tmp_path / "firmware.elf") out = capsys.readouterr().out assert "Flash: " in out assert "(used 827455 bytes from 1835008 bytes)" in out + + +def test_print_summary_flash_line_derives_from_elf( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS + sections; NOBITS and non-alloc sections are excluded.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes( + _elf_bytes( + [ + (1, 0x6, 700000), # PROGBITS, alloc+exec: counted + (1, 0x2, 24215), # PROGBITS, alloc: counted + (8, 0x2, 50000), # NOBITS (.bss): excluded + (1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded + ] + ) + ) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 724215 bytes from 1835008 bytes)" in out + + +@pytest.mark.parametrize( + "data", + [ + pytest.param([1, 2], id="top_level_list"), + pytest.param({"version": "1.1", "layout": None}, id="layout_null"), + pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"), + ], +) +def test_print_summary_handles_unexpected_shapes( + data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A foreign-schema size json degrades to a warning, never a traceback.""" + size_json = _write_size_json(tmp_path, data) + _print_summary_ram_only(tmp_path, size_json) + assert capsys.readouterr().out == "" + + +def test_print_summary_skips_flash_on_zero_app_partition( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size app partition skips the Flash line rather than printing + a from-0-bytes figure CI would record.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n" + ) + print_summary(size_json, partitions, tmp_path / "firmware.elf") + out = capsys.readouterr().out + assert "Flash:" not in out + + +def test_print_summary_skips_flash_on_unreadable_partitions( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unreadable partitions.csv is non-fatal (chmod tricks don't work + for root in CI containers, so simulate the OSError instead).""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + with patch( + "esphome.espidf.size_summary._find_app_partition_size", + side_effect=PermissionError("denied"), + ): + print_summary(size_json, partitions, tmp_path / "firmware.elf") + assert "Flash:" not in capsys.readouterr().out + + +def test_print_summary_flash_falls_back_on_bad_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero or non-int total_size falls back to the ELF instead of + printing a used-0-bytes line CI would read as a real measurement.""" + data = _s3_size_data() + data["total_size"] = 0 + size_json = _write_size_json(tmp_path, data) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)])) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 4096 bytes from 1835008 bytes)" in out + + +_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)]) + + +@pytest.mark.parametrize( + ("elf_bytes", "with_partitions"), + [ + pytest.param(None, True, id="missing_elf"), + pytest.param(b"junk", True, id="not_an_elf"), + pytest.param( + _elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize" + ), + pytest.param(_GOOD_ELF[:60], True, id="truncated_table"), + pytest.param(_elf_bytes([]), True, id="no_sections"), + pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"), + pytest.param(_GOOD_ELF, False, id="missing_partitions"), + ], +) +def test_print_summary_skips_flash_on_bad_input( + elf_bytes: bytes | None, + with_partitions: bool, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + firmware_elf = tmp_path / "firmware.elf" + if elf_bytes is not None: + firmware_elf.write_bytes(elf_bytes) + if with_partitions: + _write_partitions(tmp_path) + print_summary(size_json, tmp_path / "partitions.csv", firmware_elf) + out = capsys.readouterr().out + assert "RAM:" in out + assert "Flash:" not in out + # ELF problems warn (anomaly after a successful build); a missing + # partitions.csv stays at debug + warned = any( + r.levelname == "WARNING" and "Skipping Flash summary" in r.message + for r in caplog.records + ) + assert warned == with_partitions From 8ac3dba11ac3669961ad833b324c1c1142463d38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:40:30 -0500 Subject: [PATCH 190/316] [esp32] Add a flash chip option that drops the unused flash vendor drivers (#19217) --- esphome/components/esp32/__init__.py | 42 +++++++++++ esphome/core/application.cpp | 34 ++++++++- .../esp32/config/flash_chip_gd.yaml | 9 +++ .../esp32/config/flash_chip_generic.yaml | 9 +++ .../esp32/config/flash_chip_mxic_opi_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 73 +++++++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 1 + tests/components/esp32/test.esp32-s3-idf.yaml | 1 + 8 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/esp32/config/flash_chip_gd.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_generic.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c5b9c5df6..748be9ae4c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -111,6 +111,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_NVS_ENCRYPTION = "nvs_encryption" @@ -464,6 +465,20 @@ ESP32_CHIP_REVISIONS = { "3.1": "CONFIG_ESP32_REV_MIN_3_1", } +# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM +# and only the one matching the flash ID is ever used +ESP32_FLASH_CHIPS = { + "gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", + "issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP", + "mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP", + "winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP", + "boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP", + "th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP", + "mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", +} +FLASH_CHIP_GENERIC = "generic" +FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only + # Socket limit configuration for ESP-IDF # ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10 DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default @@ -1533,6 +1548,25 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM], ) ) + if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None: + opi = flash_chip == FLASH_CHIP_OPI + if opi and config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) + elif opi != (config.get(CONF_FLASH_MODE) == "opi"): + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'" + if opi + else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match " + f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32P4 and config.get(CONF_ENGINEERING_SAMPLE) is not None @@ -1971,6 +2005,9 @@ FRAMEWORK_SCHEMA = cv.Schema( *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, + cv.Optional(CONF_FLASH_CHIP): cv.one_of( + FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True + ), # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -2765,6 +2802,11 @@ async def to_code(config): add_idf_sdkconfig_option(flag, rev == min_rev) cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET") + # Keep only the flash vendor driver the board needs; the boot log names it + if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None: + for chip, flag in ESP32_FLASH_CHIPS.items(): + add_idf_sdkconfig_option(flag, chip == flash_chip) + # Use SRAM1 region as IRAM on ESP32 (original) variant # This provides an additional 40KB of IRAM by using SRAM1 memory that was previously # reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 38d3503c2c..50d1c61959 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#endif +// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all +#define ESPHOME_FLASH_VENDOR_DRIVERS \ + (CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP) #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -157,8 +170,25 @@ void Application::process_dump_config_() { esp_chip_info(&chip_info); ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); -#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM)) - static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; + [[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 0 + { + // Only the driver in use earns its IRAM; with several linked at least one is idle + const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 1 + constexpr bool idle_driver = true; +#else + const bool idle_driver = flash_driver == &esp_flash_chip_generic; +#endif + if (idle_driver) { + const char *value = flash_driver->name; +#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP + if (flash_driver == &esp_flash_chip_mxic_opi) + value = "mxic_opi"; +#endif + ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH); + } + } #endif #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) { diff --git a/tests/component_tests/esp32/config/flash_chip_gd.yaml b/tests/component_tests/esp32/config/flash_chip_gd.yaml new file mode 100644 index 0000000000..6d564135c0 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_gd.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: gd diff --git a/tests/component_tests/esp32/config/flash_chip_generic.yaml b/tests/component_tests/esp32/config/flash_chip_generic.yaml new file mode 100644 index 0000000000..8c7bcf6166 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_generic.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: generic diff --git a/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml new file mode 100644 index 0000000000..1531e749f2 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf + advanced: + flash_chip: mxic_opi diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d5d0acfb2a..a42d244ac8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,6 +10,7 @@ from typing import Any import pytest from esphome.components.esp32 import ( + ESP32_FLASH_CHIPS, KEY_FATFS_REQUIRED, KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, KEY_MBEDTLS_TLS_SERVER_REQUIRED, @@ -252,6 +253,41 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_only_on_s3", + ), + pytest.param( + { + "variant": "esp32s3", + "flash_mode": "opi", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "gd"}, + }, + }, + r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_must_match_opi_mode", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_requires_opi_mode", + ), pytest.param( { "variant": "esp32", @@ -719,6 +755,43 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +@pytest.mark.parametrize( + ("config_file", "enabled"), + [ + pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"), + pytest.param("flash_chip_generic.yaml", None, id="generic"), + pytest.param( + "flash_chip_mxic_opi_s3.yaml", + "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", + id="mxic_opi_s3", + ), + ], +) +def test_flash_chip_keeps_one_vendor_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + enabled: str | None, +) -> None: + """flash_chip enables only the chosen vendor driver.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + vendors = { + k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_") + } + assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()} + + +def test_flash_chip_unset_keeps_idf_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_chip every vendor driver stays at its ESP-IDF default.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig) + + def test_flash_mode_opi_enables_octal_flash( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 7f31fe59c6..5c7cb1d61b 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -22,6 +22,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + flash_chip: gd watchdog_timeout: 7s wifi: diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index b9a3b804a8..5bdf94e8e1 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -9,6 +9,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + flash_chip: gd disable_libc_locks_in_iram: true # Test default RAM optimization enabled disable_debug_stubs: true disable_ocd_aware: true From 91ab2290862f15d1a7144c4d457c9c866c1bf2d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:17:08 -0500 Subject: [PATCH 191/316] [core] Lint bare string literal ternaries in ESP_LOG arguments (#18908) --- esphome/components/snapshot/snapshot.cpp | 2 +- script/ci-custom.py | 146 ++++++++++++++++++++++- tests/script/test_ci_custom.py | 126 +++++++++++++++++++ 3 files changed, 267 insertions(+), 7 deletions(-) diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 995f87710e..5aa0bcd0e1 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -211,7 +211,7 @@ bool Snapshot::take_snapshot(const char *filename) { exact = !name.empty(); if (name_changed) { ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, - name.empty() ? "a name made from the time" : name.c_str()); + name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); } } if (name.empty()) { diff --git a/script/ci-custom.py b/script/ci-custom.py index 286dda85b9..6672760b06 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -3,6 +3,7 @@ import argparse import codecs import collections +from collections.abc import Iterator import fnmatch import functools import os.path @@ -1138,7 +1139,56 @@ def lint_no_std_bind(fname, match): ) -LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL) +LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") +# Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : +# inside them are never seen. A char literal is exactly one (escaped) char so a digit separator +# like 1'000'000 cannot open one. +CPP_COMMENT_RE = r"//[^\n]*|/\*.*?\*/" +CPP_SKIP_RE = ( + CPP_COMMENT_RE + + r'|R"(?P[^(\s]*)\(.*?\)(?P=raw_delim)"|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\\n]|\\.)\'' +) +LOG_CALL_TOKEN_RE = re.compile(CPP_SKIP_RE + r"|[()]", re.DOTALL) +# The last alternative matches a ? or : followed (after spaces or comments) by an opening quote, +# i.e. a string literal used as a ternary branch. +LOG_TERNARY_LITERAL_RE = re.compile( + CPP_SKIP_RE + r"|[?:](?:\s|" + CPP_COMMENT_RE + r')*(?=")', re.DOTALL +) +# A bare NOLINT; a clang-tidy NOLINT(check-name) is aimed at a different tool. +NOLINT_RE = re.compile(r"\bNOLINT\b(?!\()") + + +def _line_col(content: str, pos: int) -> tuple[int, int]: + """1-based line and column of an offset in content.""" + return content.count("\n", 0, pos) + 1, pos - content.rfind("\n", 0, pos) + + +def _iter_log_calls(content: str) -> Iterator[tuple[int, str | None]]: + """Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren. + text is None when no matching paren exists so callers can report the call instead of skipping it.""" + for head in LOG_CALL_START_RE.finditer(content): + depth = 1 + for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()): + if tok.group(0) == "(": + depth += 1 + elif tok.group(0) == ")": + depth -= 1 + if depth == 0: + yield head.start(), content[head.start() : tok.end()] + break + else: + yield head.start(), None + + +def _unbalanced_log_call_error(content: str, pos: int) -> tuple[int, int, str]: + lineno, col = _line_col(content, pos) + return ( + lineno, + col, + "ESP_LOG call has no matching closing parenthesis, so it cannot be checked.", + ) + + LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @@ -1146,16 +1196,16 @@ LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @lint_content_check(include=cpp_include) def lint_log_multiline_continuation(fname, content): errs = [] - for log_match in LOG_MULTILINE_RE.finditer(content): - log_text = log_match.group(0) + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + errs.append(_unbalanced_log_call_error(content, log_start)) + continue for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text): # %s may expand to a whitespace prefix at runtime, skip those if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()): continue # Calculate line number from position in full content - abs_pos = log_match.start() + bad_match.start() - lineno = content.count("\n", 0, abs_pos) + 1 - col = abs_pos - content.rfind("\n", 0, abs_pos) + lineno, col = _line_col(content, log_start + bad_match.start()) errs.append( ( lineno, @@ -1173,6 +1223,90 @@ def lint_log_multiline_continuation(fname, content): return errs +def _find_ternary_literals(text: str) -> Iterator[tuple[int, str]]: + """Yield (offset, literal) for every string literal used as a ternary branch.""" + branch = False + for m in LOG_TERNARY_LITERAL_RE.finditer(text): + tok = m.group(0) + # An empty literal is merged with every other string's terminator, so it costs no RAM, + # while a PSTR("") would add its own flash array; leave it alone. + if branch and tok[0] == '"' and tok != '""': + yield m.start(), tok + branch = tok[0] in "?:" + + +# LOG_STR_LITERAL is a no op everywhere except ESP8266, so code that never builds there is skipped +# to avoid churn: platform specific sources and components for ESP32, LibreTiny, RP2 and Zephyr only. +# A component belongs here only if it has no tests/components//test.esp8266-ard.yaml. +LOG_LITERAL_LINT_EXCLUDE = [ + "*_esp32.cpp", + "*_esp32_*.cpp", + "*_esp_idf.cpp", + "*_rmt.cpp", + "*_zephyr.cpp", + "*_bk72xx.cpp", + "*_libretiny.cpp", + "*_pico_w.cpp", + "*_host.cpp", + "esphome/components/esp32*/*", + "esphome/components/bk72xx*/*", + "esphome/components/ln882h*/*", + "esphome/components/ln882x*/*", + "esphome/components/rp2*/*", + "esphome/components/zephyr*/*", + "esphome/components/host/*", + "esphome/components/libretiny*/*", + "esphome/components/bluetooth_proxy/*", + "esphome/components/bluetooth_connection/*", + "esphome/components/ble_client/*", + "esphome/components/bedjet/*", + "esphome/components/anova/*", + "esphome/components/xiaomi_ble/*", + "esphome/components/bthome_mithermometer/*", + "esphome/components/usb_host/*", + "esphome/components/zigbee/*", + "esphome/components/lvgl/*", + # Test fixtures and host only unit tests - not production embedded code + "tests/integration/fixtures/*", + "tests/components/*", +] + + +@lint_content_check(include=cpp_include, exclude=LOG_LITERAL_LINT_EXCLUDE) +def lint_log_no_bare_literal_ternary( + fname: Path, content: str +) -> list[tuple[int, int, str]]: + errs = [] + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + continue # reported by lint_log_multiline_continuation, which sees every file + # A NOLINT anywhere on the lines the call spans silences every branch in it + first_line = content.rfind("\n", 0, log_start) + 1 + last_line = content.find("\n", log_start + len(log_text)) + if NOLINT_RE.search( + content[first_line : last_line if last_line != -1 else None] + ): + continue + for offset, literal in _find_ternary_literals(log_text): + lineno, col = _line_col(content, log_start + offset) + errs.append( + ( + lineno, + col, + ( + "String literal used as a ternary branch in a log call. On ESP8266 the " + "log macro moves the format string to flash, but bare literal arguments " + "stay in RAM. Wrap each branch passed straight to the log call in " + f"{highlight('LOG_STR_LITERAL(...)')}:\n" + f" Before: {highlight(literal)}\n" + f" After: {highlight(f'LOG_STR_LITERAL({literal})')}\n" + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ), + ) + ) + return errs + + @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index d340a816c6..5b4d4fe5c8 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -4,12 +4,16 @@ The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() ca empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the NOLINT escape hatch at both placements a contributor would try. + +Also covers the ESP_LOG call scanner (_iter_log_calls) and the bare-literal-ternary lint. """ import importlib.util from pathlib import Path import sys +import pytest + SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve() sys.path.insert(0, str(SCRIPT_DIR)) _spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py") @@ -145,3 +149,125 @@ def test_nolint_at_end_of_log_line_suppresses() -> None: def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") + + +# --- ESP_LOG call scanner and bare-literal-ternary lint --- + + +def _calls(content: str) -> list[str | None]: + return [text for _, text in ci_custom._iter_log_calls(content)] + + +def _ternary_errors(content: str) -> list[tuple[int, int]]: + errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content) + return [(line, col) for line, col, _ in errs] + + +@pytest.mark.parametrize( + "content", + [ + 'ESP_LOGD(TAG, "a ) b ( c; d")', + 'ESP_LOGD(TAG, "quote \\" inside")', + "ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())", + "ESP_LOGD(TAG, \"%c%c\", '(', ')')", + "ESP_LOGD(TAG, \"%d\", 1'000'000)", + 'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")', + 'ESP_LOGD(TAG, /* :) */ "x")', + 'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")', + 'ESP_LOGD(TAG, "%s", R"x(a)"b)x")', + ], +) +def test_iter_log_calls_spans_whole_call(content: str) -> None: + calls = _calls(content + ";\nint other = (1);") + assert calls == [content] + + +def test_iter_log_calls_reports_unbalanced_call_once() -> None: + content = 'ESP_LOGD(TAG, "x";\nvoid f();' + assert _calls(content) == [None] + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert len(errs) == 1 + assert errs[0][:2] == (1, 1) + assert "no matching closing parenthesis" in errs[0][2] + assert _ternary_errors(content) == [] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + # A ; inside the format string no longer cuts the call short + ('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]), + # A \n%s continuation is exempt since %s may expand to leading whitespace + ('ESP_LOGD(TAG, "a\\n%s", x);', []), + ('ESP_LOGD(TAG, "a\\n b");', []), + ], +) +def test_multiline_continuation_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert [(line, col) for line, col, _ in errs] == expected + + +def test_exclusion_list_only_names_components_without_esp8266_tests() -> None: + root = Path(__file__).parent / ".." / ".." + for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE: + if not pattern.startswith("esphome/components/"): + continue + prefix = pattern.removeprefix("esphome/components/").split("/")[0] + comps = list((root / "esphome" / "components").glob(prefix)) + assert comps, f"{pattern!r} matches no component" + for comp in comps: + test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml" + assert not test.exists(), ( + f"{comp.name} builds for ESP8266, drop {pattern!r}" + ) + + +def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None: + # lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this + checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS} + continuation = checks["lint_log_multiline_continuation"] + ternary = checks["lint_log_no_bare_literal_ternary"] + assert continuation["exclude"] == [] + assert continuation["include"] == ternary["include"] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]), + ( + 'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));', + [], + ), + ('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]), + ('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");', + [(2, 14), (3, 14)], + ), + ("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []), + ('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]), + ('ESP_LOGD(TAG, "x:" "y %s", p);', []), + ('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");', + [(2, 14), (3, 14)], + ), + ], +) +def test_ternary_literal_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + assert _ternary_errors(content) == expected + + +def test_ternary_error_message_names_the_literal() -> None: + errs = ci_custom.lint_log_no_bare_literal_ternary( + Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));' + ) + assert len(errs) == 1 + assert 'LOG_STR_LITERAL("enabled")' in errs[0][2] From 1dbbf3f45a93834aa5d85da5de131898778d5c92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:40:22 -0500 Subject: [PATCH 192/316] [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) --- esphome/espidf/runner.py | 99 +++++++++++++++++++---- tests/unit_tests/test_espidf_runner.py | 104 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7ed11d7554..bf563a4f43 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -74,6 +74,64 @@ FILTER_IDF_LINES: list[str] = [ r"Stopping at filesystem boundary", ] +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + + +def _get_kernel32(): + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class _Utf8Console: + """Keep an attached Windows console on UTF-8 for the length of the build. + + The build tree runs in UTF-8 mode, so esp_idf_size draws its table with + Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``, + and CMake re-decodes the child's output with the console code page before + printing it, which turns the table into mojibake on any code page but + UTF-8. Every process in the build shares this console, so switching it + here covers CMake too. The old code pages go back on exit so the user's + terminal is left as it was. + + A console that is already on UTF-8 is left alone. The code page belongs to + the console, not to this process, so a build that overlaps another one + must not save UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record the old pages first so a switch that fails part way through + # still gets put back on exit. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + def main() -> int: # ---- sys.path fix-up --------------------------------------------------- @@ -269,8 +327,29 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + class _FilteredStreams: + """Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims. + + On exit each shim releases a last line that never got its + terminator. The shims made here are drained rather than whatever + ``sys.stdout`` holds by then, which the script is free to replace. + """ + + def __init__(self, filter_lines: list[str] | None) -> None: + self._stdout = _FilteringTTYStream(sys.stdout, filter_lines) + self._stderr = _FilteringTTYStream(sys.stderr, filter_lines) + + def __enter__(self) -> None: + sys.stdout = self._stdout # type: ignore[assignment] + sys.stderr = self._stderr # type: ignore[assignment] + + def __exit__(self, *exc_info: object) -> None: + # Drain stderr from a finally so a surprise from the first one + # cannot strand the second. + try: + self._stdout.drain() + finally: + self._stderr.drain() # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -288,19 +367,11 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the streams get a chance to - # release a last line that never got its terminator. Drain the shims we - # made rather than sys.stdout, which the script is free to replace, and - # report instead of raising so cleanup cannot bury the real exit code. - try: + # fall through and exit with 0. Either way the context managers drain + # the streams and put the console back on the way out, and they report + # instead of raising so cleanup cannot bury the real exit code. + with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()): runpy.run_path(script_path, run_name="__main__") - finally: - # Drain stderr from a finally so a surprise from the first one cannot - # strand the second. - try: - stdout_shim.drain() - finally: - stderr_shim.drain() return 0 diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index e4cc6e137e..71ccfee1f2 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes( # Join before leaving the block, so the reader is done rather than # racing ``Popen`` closing the pipe under it. reader.join(1.0) + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_main_runs_the_build_with_a_utf8_console( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An attached console is switched to UTF-8 and then put back.""" + kernel32 = _FakeKernel32(850, 850) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [ + ("SetConsoleCP", runner.UTF8_CODEPAGE), + ("SetConsoleOutputCP", runner.UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_main_restores_the_console_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failing build must not leave the user's console on UTF-8.""" + kernel32 = _FakeKernel32(437, 437) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + _prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py") + + with pytest.raises(SystemExit): + runner.main() + + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_main_restores_the_console_when_the_switch_fails_part_way( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failed output page switch must not strand the changed input page.""" + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +def test_main_leaves_the_console_alone_when_there_is_none( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Without a console the code page calls return 0 and nothing is set.""" + kernel32 = _FakeKernel32(0, 0) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +def test_main_leaves_a_console_already_on_utf8_alone( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An overlapping build must not save UTF-8 as the page to restore.""" + kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert runner._get_kernel32() is None From f433f99e907719715158e6c249f3a162ac5949de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:57:59 -0500 Subject: [PATCH 193/316] [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp32_hosted/__init__.py | 2 ++ .../esp32_hosted/test-no-wifi.esp32-p4-idf.yaml | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 748be9ae4c..ae1f821c17 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -262,7 +262,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back - "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 21626e432b..6943efc3cb 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None: # symbols are simply unused and never register a callback at runtime. if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: add_define("USE_ESP_NOW_HOSTED") + # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml new file mode 100644 index 0000000000..2f57abd296 --- /dev/null +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim. +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From a63dc0d9c920fb8b0c1c5084afba9e2322c432d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 10:20:41 -0500 Subject: [PATCH 194/316] [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) --- esphome/components/api/api_server.cpp | 26 +++++- esphome/components/api/api_server.h | 5 ++ esphome/components/esphome/ota/__init__.py | 18 ++--- .../components/esphome/ota/ota_esphome.cpp | 22 +++-- esphome/components/esphome/ota/ota_esphome.h | 9 ++- esphome/components/noise/__init__.py | 18 +++-- esphome/core/defines.h | 1 - tests/component_tests/ota/test_esphome_ota.py | 26 +++--- .../host_ota_encrypted_safe_mode.yaml | 13 +++ ...ost_ota_provisioned_api_key_safe_mode.yaml | 11 +++ tests/integration/host_prefs.py | 35 ++++++-- tests/integration/test_host_ota.py | 80 +++++++++++++++++-- tests/integration/test_safe_mode_loop_runs.py | 12 +-- 13 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml create mode 100644 tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e..d74de98c75 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -29,6 +29,29 @@ static const char *const TAG = "api"; // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_API_NOISE +static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL; +#endif + +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +bool load_saved_noise_psk(noise::psk_t &out) { + SavedNoisePsk saved; +#ifdef USE_PREFERENCE_KEY_LOOKUP + const bool loaded = + global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast(&saved), sizeof(saved)); +#else + // Slot backends need the reservation walk; it only lands on the record when the reservations before + // it match a normal boot, otherwise the type checked checksum fails the load + const bool loaded = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true).load(&saved); +#endif + // The all-zeros record means no key + if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk)) + return false; + out = saved.psk; + return true; +} +#endif + APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { @@ -43,8 +66,7 @@ void APIServer::setup() { #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build - uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); + this->noise_pref_ = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true); #ifndef USE_API_NOISE_PSK_FROM_YAML // A cleared record loads fine but holds no key if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e5a22dcef8..b8a71c93e7 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -43,6 +43,11 @@ struct SavedNoisePsk { noise::psk_t psk; } PACKED; // NOLINT #endif +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when +/// there is no key +bool load_saved_noise_psk(noise::psk_t &out); +#endif class APIServer final : public Component, public Controller diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bcf2a2271c..bace13c17f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -316,20 +316,16 @@ async def to_code(config: ConfigType) -> None: # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement api_conf = CORE.config.get(CONF_API) or {} - encryption_conf = config.get(CONF_ENCRYPTION) - own_key = None - if encryption_conf is not None and static_encryption_key(api_conf) is None: - own_key = encryption_conf[CONF_KEY] - if own_key is not None: + if key := static_encryption_key(config) or static_encryption_key(api_conf): + # Build time key: the ota keeps its own pointer so safe mode, which + # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) elif CONF_ENCRYPTION in api_conf: + # Runtime key: found in the api server, or in preferences in safe mode cg.add_define("USE_OTA_ENCRYPTION") - cg.add_define("USE_OTA_ENCRYPTION_FROM_API") - if static_encryption_key(api_conf) is None: - # The key arrives at runtime, so the offer has to look for it - cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") - if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if CONF_ENCRYPTION in config: cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3010df1056..d353d01d20 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,5 +1,5 @@ #include "ota_esphome.h" -#ifdef USE_OTA_ENCRYPTION_FROM_API +#ifdef USE_OTA_ENCRYPTION_PROVISIONED #include "esphome/components/api/api_server.h" #endif #ifdef USE_OTA @@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota"; #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { -#ifdef USE_OTA_ENCRYPTION_FROM_API - return api::global_api_server->get_noise_ctx(); -#else - return this->noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The api server holds the live key; safe mode never constructs it, and then + // noise_ctx_ holds the saved key setup() found, if any + if (api::global_api_server != nullptr) + return api::global_api_server->get_noise_ctx(); #endif + return this->noise_ctx_; } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; @@ -58,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() { } void ESPHomeOTAComponent::setup() { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Safe mode never constructs the api server, so read the key it saved + noise::psk_t psk; + if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) { + this->saved_psk_ = RAMAllocator().make_unique(psk); + if (this->saved_psk_ != nullptr) { + this->noise_ctx_.set_psk(this->saved_psk_->data()); + } + } +#endif this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { this->server_failed_(LOG_STR("creation")); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 68dd0ffb9e..92ba094c8d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) +#ifdef USE_OTA_ENCRYPTION /// psk points at 32 bytes that live in flash for the life of the program void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif @@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; }; - // The api server's live context when the api has encryption, else our own + // The api server's live context when it exists, otherwise our own (a build + // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); @@ -148,8 +149,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION -#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Backs noise_ctx_ in safe mode, where no api server holds the saved key + RAMUniquePtr saved_psk_; #endif RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 6067fde164..47cd4cfc67 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,11 +5,12 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" noise_ns = cg.esphome_ns.namespace("noise") @@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None: def new_psk_progmem(parent_id: ID, key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer - so the key never occupies RAM.""" - return cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decode_encryption_key(key)), - ) + so the key never occupies RAM. Components sharing one key (api and ota) + share the array.""" + decoded = decode_encryption_key(key) + arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) + if (array := arrays.get(decoded)) is None: + array = arrays[decoded] = cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decoded), + ) + return array def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fb76f90bcf..30c9dc27ed 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -283,7 +283,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 235ad902db..d8bcd7e275 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -476,43 +476,36 @@ def test_static_encryption_key() -> None: ("yaml_name", "defines_present", "defines_absent"), [ # An api key alone compiles the transport in without requiring it; - # the device uses the api server's key, not a copy + # the ota keeps its own pointer to the key so safe mode, which never + # constructs the api server, can still use it ( "api_key_offer", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A password still guards plaintext uploads on an offering device ( "api_key_offer_password", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"}, + {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # Without api encryption the ota key is the device's own ( "own_key", {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, - {"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"}, + {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A key provisioned at runtime lives in the api server; the device # offers with it once provisioned and never requires it ( "runtime_api_key", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_FROM_API", - "USE_OTA_ENCRYPTION_PROVISIONED", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"}, {"USE_OTA_ENCRYPTION_REQUIRED"}, ), # No api encryption at all keeps the noise glue out of the build @@ -522,7 +515,6 @@ def test_static_encryption_key() -> None: { "USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED", }, ), @@ -541,8 +533,10 @@ def test_encryption_offer_codegen( assert defines_present <= defines assert not (defines_absent & defines) encrypted = "USE_OTA_ENCRYPTION" in defines_present - own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present + own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + # The api shares the ota's array instead of emitting the same key twice + assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml new file mode 100644 index 0000000000..57f3d57a01 --- /dev/null +++ b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml @@ -0,0 +1,13 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + encryption: +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml new file mode 100644 index 0000000000..c00954515b --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml @@ -0,0 +1,11 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index 5f526dce5f..b160f30762 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -15,6 +15,12 @@ import os from pathlib import Path import struct +_ENTRY = struct.Struct(" Path: """Return the on-disk prefs file path for a host-platform device. @@ -42,16 +48,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: for key, data in entries.items(): if len(data) > 255: raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: - """Write a single preference entry, replacing the file's contents. +def read_host_prefs(device_name: str) -> dict[int, bytes]: + """Read the preference entries of a host-platform device; empty when + the file does not exist.""" + path = host_prefs_path(device_name) + if not path.exists(): + return {} + payload = path.read_bytes() + entries: dict[int, bytes] = {} + pos = 0 + while pos < len(payload): + key, length = _ENTRY.unpack_from(payload, pos) + pos += _ENTRY.size + entries[key] = payload[pos : pos + length] + pos += length + return entries - Returns the path that was written. - """ - return write_host_prefs(device_name, {key: data}) + +def force_safe_mode(device_name: str) -> None: + """Make the next boot of a host-platform device enter safe mode; other + saved preferences are kept.""" + entries = read_host_prefs(device_name) + entries[_SAFE_MODE_RTC_KEY] = struct.pack(" None: + """Provision PROVISIONING_PSK over the api and wait for it to activate.""" + async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -227,6 +237,31 @@ async def test_host_ota_encrypted( await dev.ota(None, API_KEY, "encrypted OTA reported failure") +@pytest.mark.asyncio +async def test_host_ota_encrypted_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Safe mode never constructs the api server, so an encrypted OTA with the + api key has to run on the ota component's own copy of that key.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # The api port never opens in safe mode, so wait for the log line instead + force_safe_mode(DEVICE_NAME) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + # The safe mode boot clears the counter, so the re-exec boots normally + await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure") + + @pytest.mark.asyncio async def test_host_ota_api_key_offer_with_password( yaml_config: str, @@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key( None, None, "plaintext upload to an unprovisioned device must succeed" ) - async with api_client_connected( - port=dev.api_port, noise_psk=ZERO_PSK - ) as client: - assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True - await asyncio.sleep(KEY_ACTIVATION_DELAY) + await _provision_key(dev, api_client_connected) key = PROVISIONING_PSK.decode() await dev.ota( @@ -319,6 +350,45 @@ async def test_host_ota_provisioned_api_key( await dev.ota(None, None, "plaintext must stay accepted on an offering device") +@pytest.mark.asyncio +async def test_host_ota_provisioned_api_key_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """Safe mode never constructs the api server, so the OTA has to load the + provisioned key from preferences itself to keep encrypting there.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _provision_key(dev, api_client_connected) + + # The saved key is already on disk; a host reboot outside an OTA just + # exits, so safe mode takes a second start + force_safe_mode(DEVICE_NAME) + key = PROVISIONING_PSK.decode() + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + await dev.ota( + None, + key, + "encrypted upload with the provisioned key must succeed in safe mode", + ) + # The re-exec boots normally and the api reads the same record + async with api_client_connected(port=dev.api_port, noise_psk=key): + pass + + @pytest.mark.asyncio async def test_host_ota_rejects_garbage( yaml_config: str, diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 652877fc09..55756fca13 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -25,19 +25,13 @@ from __future__ import annotations import asyncio import re -import struct import pytest from .conftest import run_binary -from .host_prefs import clear_host_prefs, write_host_pref +from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter -# Must match esphome::safe_mode::RTC_KEY in safe_mode.h -SAFE_MODE_RTC_KEY = 233825507 -# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC -ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE - DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs( # Compile finished successfully; pre-populate prefs so the *next* run # enters safe mode immediately. - write_host_pref( - DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" Date: Thu, 17 Sep 2026 17:38:56 +0200 Subject: [PATCH 195/316] [web_server_idf] Close stalled EventSource sessions via HTTPD (#17800) Co-authored-by: Jeroen Jansen Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/web_server/web_server.cpp | 8 +- .../web_server_idf/web_server_idf.cpp | 112 +++++++++++++++--- .../web_server_idf/web_server_idf.h | 20 +++- 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8b0dfe166f..bad3d050f3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -200,10 +200,10 @@ void DeferredUpdateEventSource::process_deferred_queue_() { deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send } else { - // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_() - // The implementations differ due to platform-specific APIs (DISCARDED vs HTTPD_SOCK_ERR_TIMEOUT, close() vs - // fd_.store(0)), but the failure counting and timeout logic should be kept in sync. If you change this logic, - // also update the ESP-IDF implementation. + // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_(). + // The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and + // closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without + // send progress. Keep that stall policy in sync when changing either side. this->consecutive_send_failures_++; if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { // Too many failures, connection is likely dead diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9550570cdc..0062556063 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -6,6 +6,7 @@ #include #include +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -728,7 +729,7 @@ bool AsyncEventSource::loop() { for (size_t i = 0; i < this->sessions_.size();) { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) - if (ses->fd_.load() == 0) { + if (ses->safe_to_delete_()) { // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) @@ -751,7 +752,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { } for (auto *rsp : incoming) { // Already disconnected? Drop it; skip on_connect_/session start on a dead session. - if (rsp->fd_.load() == 0) { + if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; } @@ -865,10 +866,16 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g } void AsyncEventSourceResponse::process_deferred_queue_() { + if (this->close_requested_) { + return; + } while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { + if (this->close_requested_ || deferred_queue_.empty()) { + return; + } // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -877,8 +884,64 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } } +void AsyncEventSourceResponse::request_close_() { + if (!this->close_requested_) { + this->close_requested_ = true; + this->deferred_queue_.clear(); + this->event_buffer_.clear(); + this->event_bytes_sent_ = 0; + this->next_close_attempt_ms_ = App.get_loop_component_start_time(); + } + + this->process_close_(); +} + +void AsyncEventSourceResponse::process_close_() { + if (!this->close_requested_ || this->close_work_queued_.load(std::memory_order_acquire)) { + return; + } + const int fd = this->fd_.load(); + if (fd == 0) { + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (static_cast(now - this->next_close_attempt_ms_) < 0) { + return; + } + + // Queue an identity-checked shutdown on the HTTPD task. The public + // httpd_sess_trigger_close() queues only a reusable fd/session slot and can + // therefore close a new client if the original peer disconnects meanwhile. + this->close_work_queued_.store(true, std::memory_order_release); + const esp_err_t err = httpd_queue_work(this->hd_, &AsyncEventSourceResponse::close_session_work, this); + this->next_close_attempt_ms_ = now + (err == ESP_OK ? CLOSE_CONFIRM_INTERVAL_MS : CLOSE_RETRY_INTERVAL_MS); + if (err == ESP_OK) { + return; + } + + this->close_work_queued_.store(false, std::memory_order_release); + if (!this->close_retry_warning_logged_) { + ESP_LOGW(TAG, "Failed to queue EventSource close (%s); retrying", esp_err_to_name(err)); + this->close_retry_warning_logged_ = true; + } +} + +void AsyncEventSourceResponse::close_session_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + // The HTTPD task remains the session owner. Shutting the socket down makes + // its next select/recv path delete the session and invoke destroy(). + shutdown(fd, SHUT_RDWR); + } + + // Release self only after the HTTPD-task callback has finished every access. + response->close_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::process_buffer_() { - if (event_buffer_.empty()) { + if (this->close_requested_ || event_buffer_.empty()) { return; } if (event_bytes_sent_ == event_buffer_.size()) { @@ -892,32 +955,33 @@ void AsyncEventSourceResponse::process_buffer_() { httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, remaining, 0); if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT) { // EAGAIN/EWOULDBLOCK - socket buffer full, try again later - // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_() - // The implementations differ due to platform-specific APIs (HTTPD_SOCK_ERR_TIMEOUT vs DISCARDED, fd_.store(0) vs - // close()), but the failure counting and timeout logic should be kept in sync. If you change this logic, also - // update the Arduino implementation. - this->consecutive_send_failures_++; - if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { - // Too many failures, connection is likely dead - ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu16 " failed sends", - this->consecutive_send_failures_); - this->fd_.store(0); // Mark for cleanup - this->deferred_queue_.clear(); + // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_(). + // The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership. + const uint32_t now = App.get_loop_component_start_time(); + if (this->send_failure_started_ms_ == 0) { + this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall. + } + if (static_cast(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) { + ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu32 " ms without send progress", + now - this->send_failure_started_ms_); + this->request_close_(); } return; } if (bytes_sent == HTTPD_SOCK_ERR_FAIL) { - // Real socket error - connection will be closed by httpd and destroy callback will be called + // Low-level asynchronous sends do not make HTTPD close the session automatically. + this->request_close_(); return; } if (bytes_sent <= 0) { // Unexpected error or zero bytes sent ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent); + this->request_close_(); return; } - // Successful send - reset failure counter - this->consecutive_send_failures_ = 0; + // Successful send - reset stall tracking + this->send_failure_started_ms_ = 0; event_bytes_sent_ += bytes_sent; // Log partial sends for debugging @@ -933,20 +997,26 @@ void AsyncEventSourceResponse::process_buffer_() { } void AsyncEventSourceResponse::loop() { + if (this->close_requested_) { + this->process_close_(); + return; + } process_buffer_(); process_deferred_queue_(); + if (this->close_requested_) + return; // One step per loop; refusals retry next pass this->entities_iterator_.try_advance(1); } bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { - if (this->fd_.load() == 0) { + if (this->fd_.load() == 0 || this->close_requested_) { return false; } process_buffer_(); - if (!event_buffer_.empty()) { + if (this->close_requested_ || !event_buffer_.empty()) { // there is still pending event data to send first return false; } @@ -1098,6 +1168,10 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e process_buffer_(); process_deferred_queue_(); + if (this->close_requested_) { + return; + } + if (!event_buffer_.empty() || !deferred_queue_.empty()) { // outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point // trying to send first diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 743d296d73..7894206102 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -301,6 +301,14 @@ class AsyncEventSourceResponse { void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); + void request_close_(); + void process_close_(); + static void close_session_work(void *arg); + + // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + bool safe_to_delete_() const { + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + } static void destroy(void *p); AsyncEventSource *server_; @@ -311,8 +319,16 @@ class AsyncEventSourceResponse { esphome::web_server::ListEntitiesIterator entities_iterator_; std::string event_buffer_; size_t event_bytes_sent_; - uint16_t consecutive_send_failures_{0}; - static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate + uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. + uint32_t next_close_attempt_ms_{0}; + // Main-loop only; the HTTPD task never reads or writes this flag. + bool close_requested_{false}; + bool close_retry_warning_logged_{false}; + // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. + std::atomic close_work_queued_{false}; + static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; + static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; + static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From f6897f2257a3533abf4445346b958f9128810c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:57:00 -0500 Subject: [PATCH 196/316] Bump codecov/codecov-action from 7.0.0 to 7.1.0 (#19382) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689baa1292..bb461678cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,7 +321,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -350,7 +350,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: run_command: empty-upload force: true From 28189ea0260e7c5e5c19717c908e4cdf11bd62bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:32:33 -0500 Subject: [PATCH 197/316] Bump docker/setup-buildx-action from 4.3.0 to 4.4.0 in the docker-actions group (#19404) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 829bdd5f98..c55b5326ec 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Determine tag and whether to push id: tag @@ -159,7 +159,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0dee8165c..efccad9ad9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From aba49c3d4f70fa2e045afb084bdbe6ff51c9efb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:32:42 -0500 Subject: [PATCH 198/316] Bump docker/build-push-action from 7.3.0 to 7.4.0 in /.github/actions/build-image (#19403) Signed-off-by: dependabot[bot] --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 133d7ca8d8..f6565a3916 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 623aec2984c67c9b2c41696e3181ba0ded74281e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 18 Sep 2026 17:18:44 -0500 Subject: [PATCH 199/316] [mdns] Advertise ota_signed when signed OTA verification is compiled in (#19407) Co-authored-by: Claude Opus 5 (1M context) --- esphome/components/mdns/mdns_component.cpp | 10 ++++++++++ esphome/zeroconf.py | 5 +++++ .../mdns/test-signed-ota.esp32-s3-idf.yaml | 15 +++++++++++++++ tests/unit_tests/test_zeroconf.py | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/components/mdns/test-signed-ota.esp32-s3-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9d1e585339..4f236153c2 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -118,6 +118,9 @@ void MDNSComponent::compile_records_(StaticVector MagicMock: """Build a fake ``AsyncServiceInfo`` with the TXT records we care about. @@ -48,6 +49,8 @@ def _make_service_info( properties[b"friendly_name"] = friendly_name.encode() if version is not None: properties[b"version"] = version.encode() + if ota_signed is not None: + properties[b"ota_signed"] = ota_signed.encode() info.properties = properties info.load_from_cache.return_value = True return info @@ -77,6 +80,7 @@ def test_added_service_populates_import_state_and_fires_callback() -> None: assert entry.project_version == "1.0.0" assert entry.network == "wifi" assert entry.friendly_name == "Living Room" + assert entry.ota_signed is False on_update.assert_called_once_with(name, entry) @@ -206,6 +210,20 @@ def test_network_defaults_to_wifi_when_txt_absent() -> None: assert discovery.import_state[name].network == "wifi" +def test_ota_signed_txt_is_parsed() -> None: + """``ota_signed=1`` marks a device that only accepts signed OTA images. + + Lets a consumer check whether it holds a key the device trusts before + an update is attempted, instead of failing at install time. + """ + discovery = DashboardImportDiscovery() + info = _make_service_info(ota_signed="1") + name = f"signed.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert discovery.import_state[name].ota_signed is True + + def test_friendly_name_optional() -> None: """``friendly_name`` may be ``None`` if the device doesn't broadcast it. From a53efcf6468a11d96c5c762b3741fae8919333e3 Mon Sep 17 00:00:00 2001 From: Xiretza Date: Fri, 18 Sep 2026 22:30:35 +0000 Subject: [PATCH 200/316] [core] fix capitalisation of "pin" (#19405) --- esphome/pins.py | 2 +- tests/component_tests/esp32/test_esp32.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/pins.py b/esphome/pins.py index d6393508ab..34a406c273 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,7 +272,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): num = conf[CONF_NUMBER] if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING): logger.warning( - "GPIO%s is a strapping PIN and should only be used for I/O with care.\n" + "GPIO%s is a strapping pin and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", num, diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a42d244ac8..3ba819dee7 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1457,7 +1457,7 @@ def test_esp32_s31_gpio_validation( pin = {CONF_NUMBER: 36, CONF_MODE: input_mode} with caplog.at_level("WARNING"): validate_supports(pin) - assert "GPIO36 is a strapping PIN" in caplog.text + assert "GPIO36 is a strapping pin" in caplog.text _TLS_SERVER_OPTIONS = ( From f94661561df60e2c68593f757d340ee0b9c770fd Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 18 Sep 2026 19:10:11 -0400 Subject: [PATCH 201/316] [socket] Fix set_sockaddr() accepting malformed IPv4 addresses (#19394) Co-authored-by: Claude Sonnet 5 --- esphome/components/socket/socket.cpp | 6 +- tests/components/socket/__init__.py | 16 ++ tests/components/socket/conftest.py | 12 -- .../components/socket/test_sockaddr_host.cpp | 146 ++++++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 tests/components/socket/__init__.py delete mode 100644 tests/components/socket/conftest.py create mode 100644 tests/components/socket/test_sockaddr_host.cpp diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 212da80312..567937a4db 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -185,7 +185,11 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return 0; } #else - server->sin_addr.s_addr = inet_addr(ip_address); + // inet_aton() reports failure separately from the address, so 255.255.255.255 needs no special case + if (inet_aton(ip_address, &server->sin_addr) == 0) { + errno = EINVAL; + return 0; + } #endif server->sin_port = htons(port); return sizeof(sockaddr_in); diff --git a/tests/components/socket/__init__.py b/tests/components/socket/__init__.py new file mode 100644 index 0000000000..8a3da97e23 --- /dev/null +++ b/tests/components/socket/__init__.py @@ -0,0 +1,16 @@ +# This file's presence makes pytest treat this directory as a package named "socket", +# shadowing the stdlib module -- no test_*.py (Python-level) tests can live here. +# required for cpp unit testing. +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NETWORK_IPV6", True) + + manifest.to_code = to_code_testing diff --git a/tests/components/socket/conftest.py b/tests/components/socket/conftest.py deleted file mode 100644 index 5d93cac232..0000000000 --- a/tests/components/socket/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configuration file for socket component tests.""" - -import pytest - -from esphome.core import CORE - - -@pytest.fixture(autouse=True) -def reset_core(): - """Reset CORE after each test.""" - yield - CORE.reset() diff --git a/tests/components/socket/test_sockaddr_host.cpp b/tests/components/socket/test_sockaddr_host.cpp new file mode 100644 index 0000000000..74ba5de8ce --- /dev/null +++ b/tests/components/socket/test_sockaddr_host.cpp @@ -0,0 +1,146 @@ +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// Independent of USE_NETWORK_IPV6 +// ========================================================================= + +TEST(SetSockaddr, IPv4Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "192.168.1.1", 6053); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_family, AF_INET); + EXPECT_EQ(ntohs(in->sin_port), 6053); + EXPECT_EQ(in->sin_addr.s_addr, htonl(0xC0A80101)); +} + +TEST(SetSockaddr, IPv4Broadcast) { + // 255.255.255.255 is INADDR_NONE's bit pattern but also a valid address. + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "255.255.255.255", 12345); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_addr.s_addr, ESPHOME_INADDR_NONE); +} + +TEST(SetSockaddr, IPv4MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "not-an-ip", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, IPv4BufferTooSmallYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(sockaddr_in) - 1, "192.168.1.1", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, StringOverloadMatchesCStringOverload) { + struct sockaddr_storage addr_c {}; + struct sockaddr_storage addr_s {}; + socklen_t len_c = set_sockaddr((struct sockaddr *) &addr_c, sizeof(addr_c), "10.0.0.5", 80); + socklen_t len_s = set_sockaddr((struct sockaddr *) &addr_s, sizeof(addr_s), std::string("10.0.0.5"), 80); + ASSERT_EQ(len_c, len_s); + EXPECT_EQ(std::memcmp(&addr_c, &addr_s, len_c), 0); +} + +TEST(FormatSockaddrTo, IPv4RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "203.0.113.7", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "203.0.113.7"); +} + +TEST(FormatSockaddrTo, UnknownFamilyYieldsEmptyString) { + struct sockaddr addr {}; + addr.sa_family = AF_UNSPEC; + char buf[SOCKADDR_STR_LEN] = {'x', '\0'}; + size_t written = format_sockaddr_to(&addr, sizeof(addr), buf); + EXPECT_EQ(written, 0u); + EXPECT_STREQ(buf, ""); +} + +// ========================================================================= +// IPv6 - this directory's __init__.py forces USE_NETWORK_IPV6 on +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(SetSockaddr, IPv6Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::1", 443); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 443); + EXPECT_TRUE(IN6_IS_ADDR_LOOPBACK(&in6->sin6_addr)); +} + +TEST(SetSockaddr, IPv6MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "gg::not-ipv6", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddrAny, FillsIPv6AnyAddressWithPort) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr_any((struct sockaddr *) &addr, sizeof(addr), 9999); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 9999); + EXPECT_TRUE(IN6_IS_ADDR_UNSPECIFIED(&in6->sin6_addr)); +} + +TEST(FormatSockaddrTo, IPv6RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "2001:db8::1", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "2001:db8::1"); +} + +TEST(FormatSockaddrTo, V4MappedFormatsAsPlainIPv4) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::ffff:198.51.100.9", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "198.51.100.9"); +} + +TEST(SocketIp, CreatesIPv6Socket) { + auto sock = socket_ip(SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + struct sockaddr_storage addr {}; + socklen_t len = sizeof(addr); + ASSERT_EQ(sock->getsockname((struct sockaddr *) &addr, &len), 0); + EXPECT_EQ(addr.ss_family, AF_INET6); +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST From c10c06eeb5ca7dbc49e7edf07a841bebbdc99b94 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:44:33 +1200 Subject: [PATCH 202/316] [ci] Add code-review agent skill for GitHub Copilot (#19370) --- .agents/skills/code-review/SKILL.md | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .agents/skills/code-review/SKILL.md diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000000..f0c2891ba4 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,116 @@ +--- +name: code-review +description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy. +--- + +# Reviewing ESPHome pull requests + +ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers +(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and +flash are scarce, and code runs unattended for months. + +`AGENTS.md` in the repository root is the full contributor guide and the +authority when it disagrees with this summary. The developer documentation at +https://developers.esphome.io explains the component lifecycle and the reasoning +behind these rules. This skill lists the concrete things worth flagging in a +review; read `AGENTS.md` for the detail behind any item. + +Only raise findings that the diff actually introduces or changes. Do not ask for +drive-by cleanup of pre-existing code the PR did not touch. + +## Memory and embedded constraints (highest value) + +Heap allocation after `setup()` is treated as a reliability bug, not a +performance nit, because it fragments a small shared heap. Flag: + +- New heap allocation on a hot path or after setup that could be avoided. +- `std::vector` where the size is known at compile time (use `std::array`, or + `StaticVector` when a `push_back` API is needed) or fixed at runtime + init (use `FixedVector`). +- Listener / child-entity registration lists stored as `std::vector`; these have + a compile-time-known count and should use `cg.slot_counter()` plus + `StaticVector`. +- `std::vector` for a byte buffer that never grows: prefer + `std::unique_ptr` or `std::array`. +- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16 + elements): a `std::vector` of a small struct with linear search is lighter. +- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided. +- `std::string` storing a value set once from config: prefer `StringRef` (the + literal already lives in flash). +- `std::string` / `std::to_string` / string-returning helpers on hot paths where + a buffer or view API exists. + +## C++ conventions + +- Include what you use: a file referencing a symbol must include the header that + declares it, even if it currently arrives transitively. New or changed uses of + a symbol need the matching include. +- Prefix all member access with `this->`. +- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase` + for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants, + trailing underscore on protected/private fields. +- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE` + (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`, + `FAIL`, or `OK` collide with SDK macros on some platforms and break the build. +- Prefer `const`/`enum` over `#define`; `#define` is only for conditional + compilation and code-generation sizes. +- Never call `millis()` in a `loop()` body; use + `App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop + period) does nothing. +- Pick the timing primitive by cadence: gated `loop()` under 250 ms, + `set_interval` at 500 ms and above. +- Do not override a base method to return the value it already returns (e.g. + `get_setup_priority()` returning `setup_priority::DATA`). +- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`. +- Required, invariant dependencies should be constructor parameters, not setters. +- Callback registration methods must be templated (`template`), not + typed as `std::function`, so lightweight forwarders avoid a heap allocation. +- Two-space indent, `using` over `typedef`, wrap at 120 columns. + +## Python conventions + +- Type-annotate every new function signature (params and return), new dataclass + fields, and new module-level variables. Import `ConfigType` from + `esphome.types`. +- Use the walrus operator to avoid a double lookup, e.g. + `if (blah := config.get(CONF_BLAH)) is not None:`. +- Reuse existing validators from `config_validation.py` (`cv.rename_key`, + `cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling. +- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in + the component's own `.py`, or in `esphome/components/const/__init__.py` when + shared. The same constant defined in three or more component files fails CI. +- State that must persist during code generation goes in `CORE.data` namespaced + under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals. +- Prefer callback-based triggers via `build_callback_automation()`; only use a + `Trigger` subclass when the forwarder needs mutable state. + +## Testing and coverage + +- New and changed lines and branches need test coverage, including defensive + early-returns, error paths, and no-op guards. A mocked-out function is not + covered; exercise the real call path too. +- Component YAML tests live in `tests/components//`. Never define + buses (uart, i2c, spi, modbus) directly in a test file: pull them from + `tests/test_build_components/common/` through dict-style `packages:` so CI can + group builds. List-style packages or top-level merge keys block grouping. +- Config-only checks use the `validate.*.yaml` prefix; compiled checks use + `test.*.yaml`. + +## Breaking changes and public API + +- Base classes under `esphome/core/` and documented config options are public + API. Undocumented `public` members of a component are internal. +- A breaking change needs justification, a migration path in the PR description, + and a deprecation window where feasible (`ESPDEPRECATED` in C++, + `cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected + lambda signature is not a breaking change. + +## Process and PR hygiene + +- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`) + or `[core]` for shared code. +- Prose in docs, comments, and commit messages should be plain English. Keep + inline comments short and only where the code is not self-explanatory; do not + restate what the code says. +- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds + `CODEOWNERS` entries for a new component. From a00d241cad0e39f54b045ce7994d4b1b0c506e74 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:08:56 -0400 Subject: [PATCH 203/316] [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418) Co-authored-by: Claude Fable 5.1 --- esphome/components/esp32/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ae1f821c17..e2e40f21bd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2792,6 +2792,11 @@ async def to_code(config): config.get(CONF_ENGINEERING_SAMPLE, False), ) + # 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. + if variant == VARIANT_ESP32C2: + add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False) + # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, # and for PSRAM users saves significant IRAM by keeping C library functions in ROM. From 7a27d5dde1a3b2a126e65118a37b1ee12adf41eb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:45:23 +0100 Subject: [PATCH 204/316] [ci] Refresh integration test durations (#19435) --- .../integration_test_durations.json | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 16748b4537..ea9885872d 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,155 +1,156 @@ { - "tests/integration/test_action_concurrent_reentry.py": 34.72, - "tests/integration/test_addressable_light_transition.py": 33.71, - "tests/integration/test_alarm_control_panel_state_transitions.py": 38.96, - "tests/integration/test_api_action_metadata.py": 33.36, - "tests/integration/test_api_action_responses.py": 26.22, - "tests/integration/test_api_action_timeout.py": 25.41, - "tests/integration/test_api_conditional_memory.py": 20.74, - "tests/integration/test_api_custom_services.py": 23.21, - "tests/integration/test_api_get_time_response_timezone.py": 25.04, - "tests/integration/test_api_homeassistant.py": 24.18, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86, - "tests/integration/test_api_list_entities_backpressure.py": 18.8, - "tests/integration/test_api_message_size_batching.py": 28.8, - "tests/integration/test_api_reboot_timeout.py": 9.47, - "tests/integration/test_api_string_lambda.py": 16.88, - "tests/integration/test_api_vv_logging.py": 17.99, - "tests/integration/test_api_zero_psk_provisioning.py": 47.07, - "tests/integration/test_areas_and_devices.py": 20.77, - "tests/integration/test_automation_wait_actions.py": 20.07, - "tests/integration/test_automations.py": 27.41, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54, - "tests/integration/test_binary_sensor_invalidate_state.py": 16.09, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7, - "tests/integration/test_build_info.py": 18.07, - "tests/integration/test_camera_mock.py": 20.44, - "tests/integration/test_climate_control_action.py": 27.97, - "tests/integration/test_climate_custom_modes.py": 26.77, - "tests/integration/test_continuation_actions.py": 12.09, - "tests/integration/test_cover_control_action.py": 19.77, - "tests/integration/test_crc8_helper.py": 12.64, - "tests/integration/test_device_id_in_state.py": 63.19, - "tests/integration/test_duplicate_entities.py": 29.26, - "tests/integration/test_entity_icon.py": 34.95, - "tests/integration/test_fan_turn_on_action.py": 25.98, - "tests/integration/test_fnv1_hash_object_id.py": 4.85, - "tests/integration/test_fnv1a_hash.py": 5.14, - "tests/integration/test_gpio_expander_cache.py": 21.0, - "tests/integration/test_host_logger_thread_safety.py": 17.51, - "tests/integration/test_host_mode_basic.py": 21.2, - "tests/integration/test_host_mode_batch_delay.py": 26.68, - "tests/integration/test_host_mode_climate_basic_state.py": 18.04, - "tests/integration/test_host_mode_climate_control.py": 29.64, - "tests/integration/test_host_mode_empty_string_options.py": 28.8, - "tests/integration/test_host_mode_entity_fields.py": 28.68, - "tests/integration/test_host_mode_fan_preset.py": 16.98, - "tests/integration/test_host_mode_many_entities.py": 39.8, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12, - "tests/integration/test_host_mode_noise_encryption.py": 52.53, - "tests/integration/test_host_mode_reconnect.py": 14.48, - "tests/integration/test_host_mode_sensor.py": 17.38, - "tests/integration/test_host_ota.py": 94.96, - "tests/integration/test_host_preferences.py": 27.11, - "tests/integration/test_host_preferences_suspend_resume.py": 21.48, - "tests/integration/test_improv_serial_uart.py": 19.43, - "tests/integration/test_large_message_batching.py": 25.67, - "tests/integration/test_legacy_area.py": 15.59, - "tests/integration/test_legacy_climate_compat.py": 18.59, - "tests/integration/test_legacy_fan_compat.py": 18.34, - "tests/integration/test_light_automations.py": 16.74, - "tests/integration/test_light_binary_effect_off_phase.py": 57.7, - "tests/integration/test_light_calls.py": 25.88, - "tests/integration/test_light_constant_brightness.py": 22.32, - "tests/integration/test_light_control_action.py": 18.15, - "tests/integration/test_light_dim_relative_action.py": 30.35, - "tests/integration/test_light_effect_zero_brightness.py": 18.38, - "tests/integration/test_light_initial_state.py": 23.93, - "tests/integration/test_light_toggle_action.py": 26.16, - "tests/integration/test_lock_automations.py": 34.13, - "tests/integration/test_logger_buffered_recursion_guard.py": 25.77, - "tests/integration/test_loop_disable_enable.py": 14.71, - "tests/integration/test_loop_interval_decoupling.py": 26.16, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47, - "tests/integration/test_lvgl_headless_render.py": 96.36, - "tests/integration/test_micros_to_millis.py": 28.76, - "tests/integration/test_multi_click_trigger.py": 19.8, - "tests/integration/test_multi_device_preferences.py": 38.85, - "tests/integration/test_noise_encryption_key_protection.py": 25.81, - "tests/integration/test_object_id_api_verification.py": 28.46, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14, - "tests/integration/test_object_id_no_friendly_name.py": 18.82, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67, - "tests/integration/test_online_image_bmp.py": 7.41, - "tests/integration/test_oversized_payloads.py": 59.52, - "tests/integration/test_preference_key_stability.py": 27.24, - "tests/integration/test_runtime_stats.py": 20.53, - "tests/integration/test_safe_mode_loop_runs.py": 10.17, - "tests/integration/test_scheduler_blocking_warning.py": 51.45, - "tests/integration/test_scheduler_bulk_cleanup.py": 22.59, - "tests/integration/test_scheduler_defer_cancel.py": 17.64, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.61, - "tests/integration/test_scheduler_defer_fifo_simple.py": 24.73, - "tests/integration/test_scheduler_defer_stress.py": 23.91, - "tests/integration/test_scheduler_heap_stress.py": 25.77, - "tests/integration/test_scheduler_internal_id_no_collision.py": 19.83, - "tests/integration/test_scheduler_interval_reschedule.py": 23.4, - "tests/integration/test_scheduler_interval_zero_coerced.py": 5.11, - "tests/integration/test_scheduler_null_name.py": 17.36, - "tests/integration/test_scheduler_numeric_id_test.py": 20.81, - "tests/integration/test_scheduler_pool.py": 17.42, - "tests/integration/test_scheduler_rapid_cancellation.py": 25.28, - "tests/integration/test_scheduler_recursive_timeout.py": 16.48, - "tests/integration/test_scheduler_removed_item_race.py": 16.14, - "tests/integration/test_scheduler_self_keyed.py": 26.19, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26, - "tests/integration/test_scheduler_string_test.py": 27.09, - "tests/integration/test_script_array_params.py": 3.6, - "tests/integration/test_script_delay_params.py": 24.74, - "tests/integration/test_script_queued.py": 17.21, - "tests/integration/test_script_queued_idle_loop.py": 3.4, - "tests/integration/test_script_wait_on_boot.py": 23.7, - "tests/integration/test_sdl_headless_screenshot.py": 19.53, - "tests/integration/test_select_stringref_trigger.py": 18.93, - "tests/integration/test_sensor_filters_delta.py": 20.85, - "tests/integration/test_sensor_filters_ring_buffer.py": 16.82, - "tests/integration/test_sensor_filters_sliding_window.py": 54.78, - "tests/integration/test_sensor_filters_value_list.py": 19.46, - "tests/integration/test_sensor_timeout_filter.py": 18.39, - "tests/integration/test_set_internal_at_boot.py": 21.69, - "tests/integration/test_snapshot_display.py": 12.64, - "tests/integration/test_socket_wake_gate_tcp.py": 13.08, - "tests/integration/test_status_flags.py": 29.54, - "tests/integration/test_strftime_to.py": 25.62, - "tests/integration/test_syslog.py": 16.25, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18, + "tests/integration/test_action_concurrent_reentry.py": 33.88, + "tests/integration/test_addressable_light_transition.py": 25.37, + "tests/integration/test_alarm_control_panel_state_transitions.py": 29.76, + "tests/integration/test_api_action_metadata.py": 25.54, + "tests/integration/test_api_action_responses.py": 21.15, + "tests/integration/test_api_action_timeout.py": 37.89, + "tests/integration/test_api_conditional_memory.py": 13.21, + "tests/integration/test_api_custom_services.py": 18.72, + "tests/integration/test_api_get_time_response_timezone.py": 19.55, + "tests/integration/test_api_homeassistant.py": 32.09, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04, + "tests/integration/test_api_list_entities_backpressure.py": 14.96, + "tests/integration/test_api_message_size_batching.py": 22.77, + "tests/integration/test_api_reboot_timeout.py": 20.76, + "tests/integration/test_api_string_lambda.py": 35.07, + "tests/integration/test_api_vv_logging.py": 17.17, + "tests/integration/test_api_zero_psk_provisioning.py": 27.91, + "tests/integration/test_areas_and_devices.py": 23.45, + "tests/integration/test_automation_wait_actions.py": 21.21, + "tests/integration/test_automations.py": 30.73, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97, + "tests/integration/test_binary_sensor_invalidate_state.py": 17.7, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43, + "tests/integration/test_build_info.py": 23.12, + "tests/integration/test_camera_mock.py": 21.23, + "tests/integration/test_climate_control_action.py": 26.55, + "tests/integration/test_climate_custom_modes.py": 24.85, + "tests/integration/test_continuation_actions.py": 14.94, + "tests/integration/test_cover_control_action.py": 25.35, + "tests/integration/test_crc8_helper.py": 19.94, + "tests/integration/test_device_id_in_state.py": 57.04, + "tests/integration/test_duplicate_entities.py": 17.91, + "tests/integration/test_entity_icon.py": 23.81, + "tests/integration/test_fan_turn_on_action.py": 14.88, + "tests/integration/test_fnv1_hash_object_id.py": 21.14, + "tests/integration/test_fnv1a_hash.py": 16.96, + "tests/integration/test_gpio_expander_cache.py": 16.1, + "tests/integration/test_host_logger_thread_safety.py": 17.96, + "tests/integration/test_host_mode_basic.py": 8.17, + "tests/integration/test_host_mode_batch_delay.py": 18.78, + "tests/integration/test_host_mode_climate_basic_state.py": 28.88, + "tests/integration/test_host_mode_climate_control.py": 20.11, + "tests/integration/test_host_mode_empty_string_options.py": 21.1, + "tests/integration/test_host_mode_entity_fields.py": 22.3, + "tests/integration/test_host_mode_fan_preset.py": 18.12, + "tests/integration/test_host_mode_many_entities.py": 47.88, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94, + "tests/integration/test_host_mode_noise_encryption.py": 30.68, + "tests/integration/test_host_mode_reconnect.py": 4.8, + "tests/integration/test_host_mode_sensor.py": 17.36, + "tests/integration/test_host_ota.py": 94.88, + "tests/integration/test_host_preferences.py": 20.49, + "tests/integration/test_host_preferences_suspend_resume.py": 24.17, + "tests/integration/test_improv_serial_uart.py": 22.53, + "tests/integration/test_large_message_batching.py": 17.54, + "tests/integration/test_legacy_area.py": 23.03, + "tests/integration/test_legacy_climate_compat.py": 19.3, + "tests/integration/test_legacy_fan_compat.py": 14.69, + "tests/integration/test_light_automations.py": 21.44, + "tests/integration/test_light_binary_effect_off_phase.py": 29.84, + "tests/integration/test_light_calls.py": 26.72, + "tests/integration/test_light_constant_brightness.py": 29.08, + "tests/integration/test_light_control_action.py": 22.44, + "tests/integration/test_light_dim_relative_action.py": 16.57, + "tests/integration/test_light_effect_zero_brightness.py": 32.2, + "tests/integration/test_light_initial_state.py": 18.42, + "tests/integration/test_light_toggle_action.py": 16.23, + "tests/integration/test_lock_automations.py": 28.17, + "tests/integration/test_logger_buffered_recursion_guard.py": 23.52, + "tests/integration/test_loop_disable_enable.py": 19.4, + "tests/integration/test_loop_interval_decoupling.py": 27.32, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47, + "tests/integration/test_lvgl_headless_render.py": 58.56, + "tests/integration/test_micros_to_millis.py": 21.91, + "tests/integration/test_multi_click_trigger.py": 25.74, + "tests/integration/test_multi_device_preferences.py": 24.1, + "tests/integration/test_noise_encryption_key_protection.py": 22.54, + "tests/integration/test_object_id_api_verification.py": 14.59, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88, + "tests/integration/test_object_id_no_friendly_name.py": 40.23, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62, + "tests/integration/test_online_image_bmp.py": 8.46, + "tests/integration/test_oversized_payloads.py": 41.29, + "tests/integration/test_preference_key_stability.py": 33.97, + "tests/integration/test_runtime_stats.py": 23.77, + "tests/integration/test_safe_mode_loop_runs.py": 7.96, + "tests/integration/test_scheduler_blocking_warning.py": 45.2, + "tests/integration/test_scheduler_bulk_cleanup.py": 16.2, + "tests/integration/test_scheduler_defer_cancel.py": 19.87, + "tests/integration/test_scheduler_defer_cancel_regular.py": 15.93, + "tests/integration/test_scheduler_defer_fifo_simple.py": 14.12, + "tests/integration/test_scheduler_defer_stress.py": 24.55, + "tests/integration/test_scheduler_heap_stress.py": 19.84, + "tests/integration/test_scheduler_internal_id_no_collision.py": 18.42, + "tests/integration/test_scheduler_interval_reschedule.py": 22.37, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.69, + "tests/integration/test_scheduler_null_name.py": 22.08, + "tests/integration/test_scheduler_numeric_id_test.py": 18.45, + "tests/integration/test_scheduler_pool.py": 16.04, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.32, + "tests/integration/test_scheduler_recursive_timeout.py": 24.06, + "tests/integration/test_scheduler_removed_item_race.py": 24.2, + "tests/integration/test_scheduler_self_keyed.py": 19.48, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9, + "tests/integration/test_scheduler_string_test.py": 18.13, + "tests/integration/test_script_array_params.py": 8.06, + "tests/integration/test_script_delay_params.py": 13.13, + "tests/integration/test_script_queued.py": 17.67, + "tests/integration/test_script_queued_idle_loop.py": 16.33, + "tests/integration/test_script_wait_on_boot.py": 14.01, + "tests/integration/test_sdl_headless_screenshot.py": 12.91, + "tests/integration/test_select_stringref_trigger.py": 17.66, + "tests/integration/test_sensor_filters_delta.py": 25.94, + "tests/integration/test_sensor_filters_ring_buffer.py": 17.25, + "tests/integration/test_sensor_filters_sliding_window.py": 44.9, + "tests/integration/test_sensor_filters_value_list.py": 16.0, + "tests/integration/test_sensor_raw_state.py": 42.47, + "tests/integration/test_sensor_timeout_filter.py": 23.58, + "tests/integration/test_set_internal_at_boot.py": 21.55, + "tests/integration/test_snapshot_display.py": 7.58, + "tests/integration/test_socket_wake_gate_tcp.py": 15.76, + "tests/integration/test_status_flags.py": 37.76, + "tests/integration/test_strftime_to.py": 19.69, + "tests/integration/test_syslog.py": 21.44, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98, "tests/integration/test_template_climate_basic.py": 20.51, - "tests/integration/test_template_climate_custom_modes.py": 27.73, - "tests/integration/test_template_climate_nonoptimistic.py": 26.35, - "tests/integration/test_template_climate_on_control_ordering.py": 26.55, - "tests/integration/test_template_climate_publish_all_fields.py": 17.59, - "tests/integration/test_template_climate_sensor_push.py": 22.04, - "tests/integration/test_template_climate_set_actions.py": 16.82, - "tests/integration/test_template_climate_two_point_temperature.py": 25.13, - "tests/integration/test_template_text_save.py": 25.36, - "tests/integration/test_text_command.py": 18.79, - "tests/integration/test_text_sensor_raw_state.py": 17.07, - "tests/integration/test_uart_mock_ld2410.py": 59.58, - "tests/integration/test_uart_mock_ld2412.py": 59.4, - "tests/integration/test_uart_mock_ld2420.py": 45.27, - "tests/integration/test_uart_mock_ld2450.py": 27.96, - "tests/integration/test_uart_mock_modbus.py": 562.45, - "tests/integration/test_udp.py": 7.48, - "tests/integration/test_use_address_runtime.py": 17.27, - "tests/integration/test_valve_control_action.py": 18.33, - "tests/integration/test_varint_five_byte_device_id.py": 17.59, - "tests/integration/test_wait_until_mid_loop_timing.py": 16.93, - "tests/integration/test_wait_until_on_boot.py": 19.96, - "tests/integration/test_wait_until_ordering.py": 16.19, - "tests/integration/test_wait_until_reentrant_restart.py": 23.67, - "tests/integration/test_wake_loop_forces_phase_b.py": 17.58, - "tests/integration/test_water_heater_template.py": 21.96 + "tests/integration/test_template_climate_custom_modes.py": 16.94, + "tests/integration/test_template_climate_nonoptimistic.py": 19.28, + "tests/integration/test_template_climate_on_control_ordering.py": 15.47, + "tests/integration/test_template_climate_publish_all_fields.py": 18.9, + "tests/integration/test_template_climate_sensor_push.py": 14.96, + "tests/integration/test_template_climate_set_actions.py": 16.04, + "tests/integration/test_template_climate_two_point_temperature.py": 23.72, + "tests/integration/test_template_text_save.py": 15.49, + "tests/integration/test_text_command.py": 14.21, + "tests/integration/test_text_sensor_raw_state.py": 16.32, + "tests/integration/test_uart_mock_ld2410.py": 44.46, + "tests/integration/test_uart_mock_ld2412.py": 85.51, + "tests/integration/test_uart_mock_ld2420.py": 33.99, + "tests/integration/test_uart_mock_ld2450.py": 25.3, + "tests/integration/test_uart_mock_modbus.py": 522.13, + "tests/integration/test_udp.py": 13.7, + "tests/integration/test_use_address_runtime.py": 33.8, + "tests/integration/test_valve_control_action.py": 13.0, + "tests/integration/test_varint_five_byte_device_id.py": 25.11, + "tests/integration/test_wait_until_mid_loop_timing.py": 22.73, + "tests/integration/test_wait_until_on_boot.py": 10.45, + "tests/integration/test_wait_until_ordering.py": 3.64, + "tests/integration/test_wait_until_reentrant_restart.py": 18.73, + "tests/integration/test_wake_loop_forces_phase_b.py": 22.85, + "tests/integration/test_water_heater_template.py": 31.06 } From 5764a72f1b0159ff1aa11d5d6b2ec7b840fcf540 Mon Sep 17 00:00:00 2001 From: Iago Veiga Date: Mon, 21 Sep 2026 23:26:06 +0200 Subject: [PATCH 205/316] [syslog] Add configurable RFC 3164 and RFC 5424 formats (#18993) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/syslog/__init__.py | 12 +++++- esphome/components/syslog/esphome_syslog.cpp | 38 ++++++++++++------- esphome/components/syslog/esphome_syslog.h | 7 ++++ .../syslog/test-rfc5424.esp32-idf.yaml | 8 ++++ tests/integration/fixtures/syslog.yaml | 1 + tests/integration/test_syslog.py | 37 +++++++++++++----- 6 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 tests/components/syslog/test-rfc5424.esp32-idf.yaml diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index bf7fe9a307..e25567c783 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list from esphome.components.time import RealTimeClock from esphome.components.udp import CONF_UDP_ID import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID +from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID from esphome.cpp_types import Component, Parented from esphome.types import ConfigType @@ -14,6 +14,12 @@ DEPENDENCIES = ["udp", "logger", "time"] syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) +SyslogFormat = syslog_ns.enum("SyslogFormat") + +SYSLOG_FORMATS = { + "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, + "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, +} CONF_STRIP = "strip" CONF_FACILITY = "facility" @@ -25,6 +31,9 @@ CONFIG_SCHEMA = udp.UDP_SCHEMA.extend( cv.Optional(CONF_LEVEL, default="DEBUG"): is_log_level, cv.Optional(CONF_STRIP, default=True): cv.boolean, cv.Optional(CONF_FACILITY, default=16): cv.int_range(0, 23), + cv.Optional(CONF_FORMAT, default="RFC3164"): cv.enum( + SYSLOG_FORMATS, upper=True + ), } ) @@ -41,3 +50,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, parent) cg.add(var.set_strip(config[CONF_STRIP])) cg.add(var.set_facility(config[CONF_FACILITY])) + cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 790d08ffa6..a40829f315 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -49,27 +49,37 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP) char packet[508]; size_t offset = 0; - size_t remaining = sizeof(packet); - // Write PRI - abort if this fails as packet would be malformed offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri); if (offset == 0) { return; // PRI always produces at least "<0>" (3 chars), so 0 means error } - remaining -= offset; - - // Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails) auto now = this->time_->now(); - size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0; - if (ts_written > 0) { - offset += ts_written; - } else if (remaining > 0) { - packet[offset++] = '-'; - } + if (this->format_ == SYSLOG_FORMAT_RFC5424) { + offset = buf_append_str(packet, sizeof(packet), offset, "1 "); - // Write hostname, tag, and message - offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, - message); + char timestamp[32]; + size_t timestamp_len = now.is_valid() ? now.strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z") : 0; + if (timestamp_len == 24) { + // ESPTime formats the numeric offset as +HHMM. RFC 3339 requires +HH:MM. + timestamp[25] = '\0'; + timestamp[24] = timestamp[23]; + timestamp[23] = timestamp[22]; + timestamp[22] = ':'; + offset = buf_append_printf(packet, sizeof(packet), offset, "%s", timestamp); + } else { + offset = buf_append_str(packet, sizeof(packet), offset, "-"); + } + offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s - - - %.*s", App.get_name().c_str(), tag, + (int) len, message); + } else { + // RFC 3164 has no NILVALUE. If the clock is invalid, omit TIMESTAMP so a relay can add it. + if (now.is_valid()) { + offset += now.strftime(packet + offset, sizeof(packet) - offset, "%b %e %H:%M:%S "); + } + offset = buf_append_printf(packet, sizeof(packet), offset, "%s %s: %.*s", App.get_name().c_str(), tag, (int) len, + message); + } // Clamp to exclude null terminator position if buffer was filled if (offset >= sizeof(packet)) { offset = sizeof(packet) - 1; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index 4a76f9ac62..f4b2a7dd62 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,6 +7,11 @@ #ifdef USE_NETWORK namespace esphome::syslog { +enum SyslogFormat : uint8_t { + SYSLOG_FORMAT_RFC3164, + SYSLOG_FORMAT_RFC5424, +}; + class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} @@ -14,6 +19,7 @@ class Syslog final : public Component, public Parented { void on_log(uint8_t level, const char *tag, const char *message, size_t message_len); void set_strip(bool strip) { this->strip_ = strip; } void set_facility(int facility) { this->facility_ = facility; } + void set_format(SyslogFormat format) { this->format_ = format; } protected: int log_level_; @@ -21,6 +27,7 @@ class Syslog final : public Component, public Parented { time::RealTimeClock *time_; bool strip_{true}; int facility_{16}; + SyslogFormat format_{SYSLOG_FORMAT_RFC3164}; }; } // namespace esphome::syslog #endif diff --git a/tests/components/syslog/test-rfc5424.esp32-idf.yaml b/tests/components/syslog/test-rfc5424.esp32-idf.yaml new file mode 100644 index 0000000000..3a49aeb57d --- /dev/null +++ b/tests/components/syslog/test-rfc5424.esp32-idf.yaml @@ -0,0 +1,8 @@ +<<: !include common.yaml + +syslog: + port: 514 + strip: true + level: info + facility: 16 + format: RFC5424 diff --git a/tests/integration/fixtures/syslog.yaml b/tests/integration/fixtures/syslog.yaml index df376087e3..dd48938a0a 100644 --- a/tests/integration/fixtures/syslog.yaml +++ b/tests/integration/fixtures/syslog.yaml @@ -41,3 +41,4 @@ syslog: level: DEBUG strip: true facility: 16 + format: SYSLOG_FORMAT_PLACEHOLDER diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index 0567164805..293824e2f2 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -31,14 +31,25 @@ class ParsedSyslogMessage(TypedDict): # RFC 3164 syslog message pattern: # TIMESTAMP HOSTNAME TAG: MESSAGE # Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running... -SYSLOG_PATTERN = re.compile( +RFC3164_PATTERN = re.compile( r"<(\d+)>" # PRI (priority = facility * 8 + severity) - r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-") - r" (\S+)" # HOSTNAME + r"(?:(\S+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP + r"(\S+)" # HOSTNAME r" (\S+):" # TAG r" (.*)" # MESSAGE ) +# RFC 5424 syslog message pattern: +# VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG +RFC5424_PATTERN = re.compile( + r"<(\d+)>1 " + r"(\S+) " + r"(\S+) " + r"(\S+) " + r"- - - " + r"(.*)" +) + @dataclass class SyslogReceiver: @@ -123,9 +134,10 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: sock.close() -def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: +def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None: """Parse a syslog message and return its components.""" - match = SYSLOG_PATTERN.match(msg) + pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN + match = pattern.fullmatch(msg) if not match: return None pri, timestamp, hostname, tag, message = match.groups() @@ -137,7 +149,7 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: pri=pri_val, facility=facility, severity=severity, - timestamp=timestamp, + timestamp=timestamp or "", hostname=hostname, tag=tag, message=message, @@ -145,15 +157,18 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: @pytest.mark.asyncio +@pytest.mark.parametrize("format_", ["RFC3164", "RFC5424"]) async def test_syslog( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, + format_: str, ) -> None: """Test syslog component sends properly formatted messages.""" async with syslog_udp_listener() as (udp_port, receiver): # Replace the placeholder port in the config config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port)) + config = config.replace("SYSLOG_FORMAT_PLACEHOLDER", format_) async with run_compiled(config), api_client_connected() as client: # Verify device is running @@ -176,7 +191,7 @@ async def test_syslog( # Parse and validate all messages parsed_messages: list[ParsedSyslogMessage] = [] for msg in receiver.messages: - parsed = parse_syslog_message(msg) + parsed = parse_syslog_message(msg, format_) if parsed: parsed_messages.append(parsed) @@ -204,12 +219,16 @@ async def test_syslog( f"Unexpected hostname: {parsed['hostname']}" ) - # Validate timestamp format (BSD or NILVALUE) - if parsed["timestamp"] != "-": + if format_ == "RFC3164" and parsed["timestamp"]: assert re.match( r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}", parsed["timestamp"], ), f"Invalid timestamp format: {parsed['timestamp']}" + elif format_ == "RFC5424" and parsed["timestamp"] != "-": + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}", + parsed["timestamp"], + ), f"Invalid timestamp format: {parsed['timestamp']}" # Verify we see different severity levels in the logs severities_seen = {p["severity"] for p in parsed_messages} From 81dd57b402b2d98c4a2a93bfb1f43337415ffaa5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:00 +0100 Subject: [PATCH 206/316] Bump platformdirs from 4.11.8 to 4.11.10 (#19443) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 15ee7af7c8..cb5a725676 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.8 # native esp-idf toolchain global cache dir +platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 8f3f25b8325705554c099a964dce0dc4f4b40dbf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:22 +0100 Subject: [PATCH 207/316] Bump codecov/codecov-action from 7.1.0 to 7.1.1 (#19446) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb461678cb..08cd852f02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,7 +321,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -350,7 +350,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: run_command: empty-upload force: true From cd012b57ed9e130ba46e30023253e6c337853275 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:36 +0100 Subject: [PATCH 208/316] Bump ruff from 0.16.7 to 0.16.8 (#19441) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> 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 0c7600ec12..a024185768 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.7 + rev: v0.16.8 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 81208d7cb7..95e05d1be9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.8 # .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.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 3b4a6f21f9f27cccba5b7f7f504d51d4cb09c8a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:52 +0100 Subject: [PATCH 209/316] Bump filelock from 3.32.6 to 4.0.0 (#19442) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb5a725676..abff6dfa34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==3.32.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.0 # 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.2 From 352121d60f00464e0ff6b7feef31f521634176ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:02 +0100 Subject: [PATCH 210/316] Bump esphome/workflows/.github/workflows/lock.yml from 2026.8.1 to 2026.9.0 (#19448) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index e09e9bf2d1..74ac5fe05a 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 + uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0 From cd50f949aee638d841934643efb9487d36710154 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:25 +0100 Subject: [PATCH 211/316] Bump github/codeql-action/analyze from 4.38.0 to 4.38.1 (#19447) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0daae69ccf..6f1a6efc66 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:${{matrix.language}}" From 632f63b34bebc5a493990879ed5747a8f8fc7b5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:33 +0100 Subject: [PATCH 212/316] Bump github/codeql-action/init from 4.38.0 to 4.38.1 (#19445) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6f1a6efc66..efa6ccf514 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From a78e955691e054983c92f925584795d31ff5e7cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:45 +0100 Subject: [PATCH 213/316] Bump esphome/workflows/.github/workflows/stale.yml from a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 to cc3e76de337dc59bc1cba8da58d963cd23b873f1 (#19444) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 38d2418ac6..43b7e6663d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main + uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From f4aaa595f80e4529f4421f512fc7d781fd9a6dba Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:59:19 +0000 Subject: [PATCH 214/316] Bump aioesphomeapi from 46.4.1 to 46.5.0 (#19463) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index abff6dfa34..79bf05db27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.4.1 +aioesphomeapi==46.5.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 44cf2b594a9d5cf3a534945a5449bf2d9174bab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:00 +0100 Subject: [PATCH 215/316] Bump filelock from 4.0.0 to 4.0.1 (#19464) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 79bf05db27..ef1b00a73a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==4.0.0 # 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.1 # 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.2 From 2b805b897b350b679f7e34cf6314c776cc6899d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:24 +0100 Subject: [PATCH 216/316] Bump platformdirs from 4.11.10 to 4.11.11 (#19465) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ef1b00a73a..fd69353345 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.10 # native esp-idf toolchain global cache dir +platformdirs==4.11.11 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 79532f2a857b070e7dfcf97a04b4ac9102451254 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:48:50 +0200 Subject: [PATCH 217/316] [zigbee] Require mbedTLS extras for Zigbee crypto (#19457) --- esphome/components/zigbee/zigbee_esp32.py | 7 +++++ .../esp32/config/tls_zigbee_c6.yaml | 11 ++++++++ tests/component_tests/esp32/test_esp32.py | 27 ++++++++++++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp32/config/tls_zigbee_c6.yaml diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 57fa3b2a00..9aae8d4557 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_tls_extras, require_vfs_select, ) import esphome.config_validation as cv @@ -289,6 +290,12 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": ref="2.0.4", ) + # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Keep the esp32 component from trimming them out of mbedTLS. + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present include_builtin_idf_component("esp_coex") diff --git a/tests/component_tests/esp32/config/tls_zigbee_c6.yaml b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml new file mode 100644 index 0000000000..3f6904bd98 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +zigbee: + model: zigbee_test + power_source: MAINS_SINGLE_PHASE diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3ba819dee7..08f5dcc822 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1488,7 +1488,7 @@ def test_mbedtls_tls_trim_sdkconfig( assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} -_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} +_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} def test_mbedtls_tls_openthread_keeps_only_what_it_uses( @@ -1500,7 +1500,19 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: - assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False) + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + + +def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) def test_mbedtls_tls_user_sdkconfig_wins( @@ -1529,7 +1541,16 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + + +def test_mbedtls_tls_zigbee_requires_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e76edeb19ea50148de25238842b639039fa040d6 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Tue, 22 Sep 2026 17:15:38 -0700 Subject: [PATCH 218/316] [yaml_util][substitutions] Repair bad tests (#19468) --- .../yaml_util/broken_includetest.yaml | 18 ------------ .../includes/broken_included.yaml.txt | 5 ---- .../fixtures/yaml_util/missing_comp.yaml | 12 -------- tests/unit_tests/test_substitutions.py | 28 +++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 24 +--------------- 5 files changed, 29 insertions(+), 58 deletions(-) delete mode 100644 tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml delete mode 100644 tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt delete mode 100644 tests/unit_tests/fixtures/yaml_util/missing_comp.yaml diff --git a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml b/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml deleted file mode 100644 index a49ae706a4..0000000000 --- a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -substitutions: - name: original - -wifi: !include - file: includes/broken_included.yaml.txt - vars: - name: my_custom_ssid - -esphome: - # should be substituted as 'original', - # not overwritten by vars in the !include above - name: ${name} - name_add_mac_suffix: true - libraries: !include {file: includes/list.yaml, vars: {var1: Wire}} - -esp8266: - board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}} diff --git a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt b/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt deleted file mode 100644 index 6e53395c86..0000000000 --- a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt +++ /dev/null @@ -1,5 +0,0 @@ ---- -# yamllint disable-line - ssid: ${name} -# yamllint disable-line - fdf: error diff --git a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml b/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml deleted file mode 100644 index d065901ed9..0000000000 --- a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml +++ /dev/null @@ -1,12 +0,0 @@ -esphome: - name: test - -esp32: - board: esp32dev - -wifi: - ap: ~ - -image: - - id: its_a_bug - file: "mdi:bug" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 73c6e496a9..b94a70443c 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -730,6 +730,34 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: processor.resolve_package(package_config, substitutions.ContextVars(), []) +def test_include_non_existent_file(tmp_path: Path) -> None: + """!include with a file that cannot be opened raises cv.Invalid.""" + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include non_existent_include_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'non_existent_include_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + +def test_include_broken_file(tmp_path: Path) -> None: + """!include with a file that cannot be parsed raises cv.Invalid.""" + broken_file = tmp_path / "broken_file.yaml" + broken_file.write_text("{garbage\n") + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include broken_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'broken_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: """!include with an undefined substitution variable raises cv.Invalid. diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 8e1f9c25c0..f3f73ea4dc 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -54,34 +54,12 @@ def test_include_with_vars(fixture_path: Path) -> None: assert actual["wifi"]["ssid"] == "my_custom_ssid" -def test_loading_a_broken_yaml_file(fixture_path): - """Ensure we fallback to pure python to give good errors.""" - yaml_file = fixture_path / "yaml_util" / "broken_includetest.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "broken_included.yaml" in str(err) - - -def test_loading_a_yaml_file_with_a_missing_component(fixture_path): - """Ensure we show the filename for a yaml file with a missing component.""" - yaml_file = fixture_path / "yaml_util" / "missing_comp.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing_comp.yaml" in str(err) - - def test_loading_a_missing_file(fixture_path): """We throw EsphomeError when loading a missing file.""" yaml_file = fixture_path / "yaml_util" / "missing.yaml" - try: + with pytest.raises(EsphomeError, match=r"missing.yaml"): yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing.yaml" in str(err) def test_parsing_with_custom_loader(fixture_path): From 359eba714542e65a8383409c151019eaf237f90e Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Tue, 22 Sep 2026 21:33:30 -0300 Subject: [PATCH 219/316] [fujitsu_general] Use constexpr for the protocol constants (#19486) --- .../fujitsu_general/fujitsu_general.cpp | 90 +++++++++---------- .../fujitsu_general/fujitsu_general.h | 4 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index f801239153..aca643defe 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -11,78 +11,78 @@ namespace esphome::fujitsu_general { static const char *const TAG = "fujitsu_general.climate"; // Common header -const uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; +constexpr uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; // State message - temp & fan etc. -const uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; +constexpr uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; // Util messages - off & eco etc. -const uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; +constexpr uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; // State header -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer -const uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; +constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; // Temperature -const uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; // Power on -const uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; -const uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; -const uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -const uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; -const uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; -const uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; -const uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; -const uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; +constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; // const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; // Swing -const uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; -const uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; -const uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; -const uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; -const uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; +constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; +constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -const uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; -const uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; -const uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; -const uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; -const uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; +constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; +constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; +constexpr uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; +constexpr uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; // TODO Outdoor Unit Low Noise // const uint8_t FUJITSU_GENERAL_OUTDOOR_UNIT_LOW_NOISE_BYTE14 = 0xA0; // const uint8_t FUJITSU_GENERAL_STATE_BYTE14 = 0x20; -const uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; -const uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; +constexpr uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; +constexpr uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; -const uint16_t FUJITSU_GENERAL_BIT_MARK = 420; -const uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; -const uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; +constexpr uint16_t FUJITSU_GENERAL_BIT_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; +constexpr uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; -const uint16_t FUJITSU_GENERAL_TRL_MARK = 420; -const uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; +constexpr uint16_t FUJITSU_GENERAL_TRL_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; -const uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; +constexpr uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; void FujitsuGeneralClimate::transmit_state() { if (this->mode == climate::CLIMATE_MODE_OFF) { diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 8d2ec883da..93363860d4 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -7,8 +7,8 @@ namespace esphome::fujitsu_general { -const uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH -const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius +constexpr uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH +constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius // clang-format off /** From e8d3c06b60f5a44d6ae31a902cc2e4f5b055a98f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 10:36:42 +0100 Subject: [PATCH 220/316] [core][cover] Add register_apply_action for actions that only forward values (#19469) --- AGENTS.md | 14 ++ esphome/automation.py | 193 +++++++++++++++- esphome/components/cover/__init__.py | 113 +--------- esphome/components/cover/automation.h | 44 ---- esphome/components/template/cover/__init__.py | 39 +--- esphome/config_validation.py | 1 + esphome/core/automation.h | 9 +- esphome/core/base_automation.h | 15 ++ esphome/core/helpers.cpp | 9 + esphome/core/progmem.h | 8 + esphome/cpp_generator.py | 4 +- tests/components/template/common-base.yaml | 4 +- .../integration/test_cover_control_action.py | 4 +- tests/unit_tests/test_automation.py | 213 +++++++++++++++++- tests/unit_tests/test_cpp_generator.py | 9 + 15 files changed, 485 insertions(+), 194 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 448bf49114..cf78daa5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,6 +443,20 @@ file does, and it is the authority when they disagree. The most useful starting Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + **Actions that only forward templatable values to their parent need no C++ class.** Register them + with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape. + ```python + automation.register_apply_action( + "my_component.set_gains", + schema, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + ) + ``` + The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover + the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with + `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. + * **Conditions:** ```cpp template class MyCondition : public Condition { diff --git a/esphome/automation.py b/esphome/automation.py index 3ffda50c81..373f924e4d 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,5 +1,8 @@ +from collections.abc import Callable from dataclasses import dataclass, field import logging +import string +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -18,15 +21,17 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import ( + FlashStringLiteral, LambdaExpression, MockObj, MockObjClass, TemplateArgsType, + call_lambda, ) from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType from esphome.util import Registry @@ -57,6 +62,7 @@ def maybe_conf(conf, *validators): with cv.remove_prepend_path([conf]): return validator({conf: value}) + validate.inner_schema = validator return validate @@ -207,6 +213,189 @@ validate_action_list = cv.validate_registry("action", ACTION_REGISTRY) validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) +ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) + + +def flash_string(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" + if CORE.is_esp8266: + return f"progmem_string({FlashStringLiteral(value)})" + return str(cg.safe_exp(value)) + + +@dataclass(frozen=True) +class ApplyCall: + """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. + + Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a + path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use + ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda bypasses + it. The statement is skipped when none of its keys is set, always emitted when it has no + keys, and a partial set is a config error. + """ + + target: str + args: tuple[tuple[Any, ...], ...] = () + + def __post_init__(self) -> None: + fields = [ + f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None + ] + if any(fields): + raise ValueError( + f"apply target {self.target!r}: only bare {{}} placeholders" + ) + if len(fields) != len(self.args): + raise ValueError( + f"apply target {self.target!r} has {len(fields)} " + f"placeholder(s) for {len(self.args)} config key(s)" + ) + if any(len(arg) not in (2, 3) for arg in self.args): + raise ValueError( + f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" + ) + + +@dataclass(frozen=True) +class ApplyField: + """One config key forwarded as ``target(value)``, or as statement ``target`` when it has ``{}``. + + Double a literal brace in a template. ``conf_key`` may be a path into nested sections. + ``type_`` may be a C++ type string using ``{parent}`` when the type is only known per + instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp`` + is not the right spelling (unit conversion belongs in the validator); a lambda bypasses it, + so the target must also take a plain ``type_``. An absent key emits nothing. + """ + + conf_key: str | tuple[str, ...] + target: str + type_: SafeExpType + const_fn: Callable[[ConfigType, Any], str] | None = None + + def call(self) -> ApplyCall: + target = self.target if "{}" in self.target else f"{self.target}({{}})" + return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),)) + + +def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any: + if isinstance(key, str): + return config.get(key) + for part in key: + if (config := config.get(part)) is None: + return None + return config + + +def _dict_schema(schema: Any) -> Any: + """The dict-backed cv.Schema inside cv.All and maybe_* wrappers, or None; cv.Any is not inspected.""" + if isinstance(schema, dict): + return cv.Schema(schema) + if isinstance(getattr(schema, "schema", None), dict): + return schema + if isinstance(schema, cv.All): + inner = schema.validators + else: + inner = ( + getattr(schema, "inner_schema", None), + ) # maybe_conf / maybe_simple_value + for candidate in inner: + if candidate is not None and (found := _dict_schema(candidate)) is not None: + return found + return None + + +def _check_key_in_schema( + name: str, schema: Any, conf_key: str | tuple[str, ...] +) -> None: + """Reject a key path the schema does not have; a typo would otherwise be a silent no-op. + + Only dict-backed schemas, also inside cv.All and maybe_* wrappers, can be checked. + """ + for part in (conf_key,) if isinstance(conf_key, str) else conf_key: + if (schema := _dict_schema(schema)) is None: + return + markers = { + getattr(marker, "schema", marker): marker for marker in schema.schema + } + if part not in markers: + raise ValueError(f"{name}: config key {part!r} is not in the schema") + schema = schema.schema[markers[part]] + + +def register_apply_action( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, +) -> None: + """Register an action that only forwards config values to its parent, with no C++ class. + + Generates one stateless function for ``ApplyAction``: parent and constants are baked + in, lambdas are called inline with the trigger args. With ``call`` every statement targets + the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. + """ + statements_spec = [ + ( + c.target, + [(arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in c.args], + ) + for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) + ] + for _, members in statements_spec: + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + parent = f"::{await cg.get_variable(config[CONF_ID])}" + # Must match ApplyAction::ApplyFn exactly for the function pointer conversion. + lambda_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + receiver = "apply_call." if call else f"{parent}->" + statements: list[str] = [] + for target, members in statements_spec: + values = [_config_lookup(config, key) for key, _, _ in members] + if members and all(value is None for value in values): + continue + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda( + value, lambda_args, return_type=type_ + ) + exprs.append(str(call_lambda(inner))) + elif const_fn is not None: + exprs.append(const_fn(config, value)) + elif type_ is cg.std_string: + exprs.append(flash_string(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + statements.append(f"{receiver}{target.format(*exprs)};") + if call: + statements = [ + f"auto apply_call = {parent}->{call}();", + *statements, + "apply_call.perform();", + ] + apply_lambda = LambdaExpression( + ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ) + return cg.new_Pvariable(action_id, template_arg, apply_lambda) + + register_action(name, ApplyAction, schema, synchronous=True)(builder) + def validate_potentially_and_condition(value): if isinstance(value, list): diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 011b2c2f04..05a9afad61 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,5 +1,3 @@ -from collections.abc import Callable -from dataclasses import dataclass import logging from esphome import automation @@ -38,14 +36,14 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -70,7 +68,6 @@ _LOGGER = logging.getLogger(__name__) cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) -CoverCall = cover_ns.class_("CoverCall") COVER_OPEN = cover_ns.COVER_OPEN COVER_CLOSED = cover_ns.COVER_CLOSED @@ -94,8 +91,6 @@ OpenAction = cover_ns.class_("OpenAction", automation.Action) CloseAction = cover_ns.class_("CloseAction", automation.Action) StopAction = cover_ns.class_("StopAction", automation.Action) ToggleAction = cover_ns.class_("ToggleAction", automation.Action) -ControlAction = cover_ns.class_("ControlAction", automation.Action) -CoverPublishAction = cover_ns.class_("CoverPublishAction", automation.Action) CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( @@ -319,107 +314,19 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@dataclass(frozen=True) -class ApplyField: - """One field in a folded-lambda action. - - `conf_key` is the YAML key looked up in `config`. When present, the - helper emits `statement_fn(target, value_expr)` into the lambda body. - `target` is whatever the statement function needs to identify the - field (typically a setter name like `"set_position"` or a struct - member like `"position"`). `type_` is the C++ return type for - `cg.process_lambda` when the value is a user lambda. - """ - - conf_key: str - target: str - type_: object - - -async def build_apply_lambda_action( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, - fields: tuple[ApplyField, ...], - prefix_args: list[tuple[object, str]], - statement_fn: Callable[[str, str], str], -) -> MockObj: - """Fold configured fields into a single stateless apply lambda action. - - Used by both `cover.control` and `cover.template.publish` (and shared - with the template/cover platform). Constants are emitted as flash - immediates; user lambdas are invoked inline so trigger args still flow. - Trigger arg types are normalized to `const std::remove_cvref_t &` - to match the ApplyFn signature for any T (value, ref, or const-ref). - """ - paren = await cg.get_variable(config[CONF_ID]) - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for field in fields: - if (value := config.get(field.conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, normalized_args, return_type=field.type_ - ) - value_expr = f"({inner})({fwd_args})" - else: - value_expr = str(cg.safe_exp(value)) - body_lines.append(statement_fn(field.target, value_expr)) - - apply_args = [ - *prefix_args, - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) - - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both dispatch to set_position. -_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = ( - ApplyField(CONF_STOP, "set_stop", cg.bool_), - ApplyField(CONF_STATE, "set_position", cg.float_), - ApplyField(CONF_POSITION, "set_position", cg.float_), - ApplyField(CONF_TILT, "set_tilt", cg.float_), +automation.register_apply_action( + "cover.control", + COVER_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + automation.ApplyField(CONF_TILT, "set_tilt", cg.float_), + call="make_call", ) -@automation.register_action( - "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True -) -async def cover_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return await build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_CONTROL_FIELDS, - prefix_args=[(CoverCall.operator("ref"), "call")], - statement_fn=lambda setter, expr: f"call.{setter}({expr});", - ) - - COVER_CONDITION_SCHEMA = cv.maybe_simple_value( {cv.Required(CONF_ID): cv.use_id(Cover)}, key=CONF_ID ) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 0a5a447ab9..20f56e1753 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -46,50 +46,6 @@ template class ToggleAction final : public Action { Cover *cover_; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. Each action stores only one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. - -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); - ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->cover_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - -template class CoverPublishAction final : public Action { - public: - using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); - CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - this->apply_(this->cover_, x...); - this->cover_->publish_state(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 0e6f96e9f5..39df5affcf 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -20,9 +20,6 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -120,17 +117,8 @@ async def to_code(config): # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both map to the position field. -_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( - cover.ApplyField(CONF_STATE, "position", cg.float_), - cover.ApplyField(CONF_POSITION, "position", cg.float_), - cover.ApplyField(CONF_TILT, "tilt", cg.float_), - cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), -) - - -@automation.register_action( +automation.register_apply_action( "cover.template.publish", - cover.CoverPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(cover.Cover), @@ -142,22 +130,11 @@ _COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField(CONF_TILT, "tilt = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", cover.CoverOperation + ), + automation.ApplyCall("publish_state()"), ) -async def cover_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - # Mutates Cover fields directly (no CoverCall) since publish is a state - # push, not a control request. - return await cover.build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_PUBLISH_FIELDS, - prefix_args=[(cover.Cover.operator("ptr"), "cover")], - statement_fn=lambda field, expr: f"cover->{field} = {expr};", - ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1623117a36..579da9a315 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2354,6 +2354,7 @@ def maybe_simple_value(*validators, **kwargs): return validator(value) return validator({key: value}) + validate.inner_schema = validator return validate diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 5f010521dc..b6058925b4 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -317,13 +317,8 @@ template class TemplatableValue { case STATIC_STRING: return std::string(this->static_str_); #ifdef USE_ESP8266 - case FLASH_STRING: { - // PROGMEM pointer — must use _P functions to access on ESP8266 - size_t len = strlen_P(this->static_str_); - std::string result(len, '\0'); - memcpy_P(result.data(), this->static_str_, len); - return result; - } + case FLASH_STRING: + return progmem_string(reinterpret_cast(this->static_str_)); #endif case NONE: default: diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 999b38bd5c..b0c4b64995 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome { @@ -252,6 +253,20 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; +/// Runs one codegen-generated function that has the parent and every field baked in, so the +/// action holds one pointer. Args pass by const reference so a std::string arg is never copied; +/// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. +template class ApplyAction final : public Action { + public: + using ApplyFn = void (*)(const std::remove_cvref_t &...); + explicit ApplyAction(ApplyFn apply) : apply_(apply) {} + + void play(const Ts &...x) override { this->apply_(x...); } + + protected: + ApplyFn apply_; +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 433d2547b0..313daacbd0 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -20,6 +20,15 @@ namespace esphome { +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str) { + auto *src = reinterpret_cast(str); + std::string result(strlen_P(src), '\0'); + memcpy_P(result.data(), src, result.size()); + return result; +} +#endif + static const char *const TAG = "helpers"; __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index d349418d02..992b3c0e92 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/hal.h" // For PROGMEM definition @@ -54,6 +55,13 @@ using ProgmemStr = const char *; namespace esphome { +/// Copies a string stored with ESPHOME_F into a std::string. +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str); +#else +inline std::string progmem_string(ProgmemStr str) { return std::string(str); } +#endif + /// Helper for C++20 string literal template arguments template struct FixedString { char data[N]{}; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 173002438a..f394337604 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1211,8 +1211,8 @@ def call_lambda(lamb: LambdaExpression) -> Expression: # Developer error if this is called with a lambda that doesn't have a return type assert lamb.return_type is not None, "Lambda must have a return type to be called" expr = lamb.content.strip() - if re.match(r"^return\b", expr) and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression + # A lone `return ;` reduces to the expression; anything longer is called as is. + if re.match(r"^return\b", expr) and expr.endswith(";") and expr.count(";") == 1: expr = RawExpression(expr[6:-1].strip()) # Don't cast if the return type is a class if isinstance(lamb.return_type, MockObjClass): diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 02aedaf167..53e1f7af6f 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -319,7 +319,7 @@ cover: logger.log: Cover is closed # Exercise cover.control / cover.template.publish action variants so they # get build coverage in CI (and so memory-impact analysis on PRs that - # touch ControlAction / CoverPublishAction sees real instances). + # touch these actions sees real instances). - platform: template name: "Template Cover Actions" id: template_cover_actions @@ -493,7 +493,7 @@ valve: stop_action: - logger.log: stop_action # Exercise valve.control with various field combinations so the - # ControlAction codegen paths get build coverage. + # valve.control codegen paths get build coverage. - valve.control: id: template_valve stop: true diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index 9c7395371b..ec9a94bf4f 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for cover ControlAction and CoverPublishAction. +"""Integration test for the cover.control and cover.template.publish actions. Tests that cover.control and cover.template.publish automation actions work correctly with the single stateless apply lambda/function pointer @@ -22,7 +22,7 @@ async def test_cover_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + """Test cover.control and cover.template.publish with constants and lambdas.""" loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: cover_state_future: asyncio.Future[CoverState] | None = None diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 07ea753360..c7c5ae998e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,12 +8,17 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + ApplyAction, + ApplyCall, + ApplyField, CallbackAutomation, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + maybe_simple_id, + register_apply_action, register_bare_action, register_bare_condition, register_parented_action, @@ -22,8 +27,9 @@ from esphome.automation import ( register_simple_condition, ) import esphome.codegen as cg +import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID +from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.util import Registry, RegistryEntry @@ -594,3 +600,208 @@ def test_shared_builders_keep_synchronous_flag( assert actions["my.simple"].synchronous is synchronous assert actions["my.bare"].synchronous is synchronous assert actions["my.parented"].synchronous is synchronous + + +async def _run_apply_action( + registries: tuple[Registry, Registry], + fields: tuple[ApplyField | ApplyCall, ...], + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + call: str | None = None, + platform: str = "esp32", +) -> RegistryEntry: + """Register an apply action and run its builder with the given config.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + actions, _ = registries + register_apply_action("my.apply", None, *fields, call=call) + entry = actions["my.apply"] + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + +def _apply_lambda(mock_cg: MockCodegen) -> str: + return str(mock_cg.new_pvariable.call_args.args[2]) + + +@pytest.mark.asyncio +async def test_register_apply_action_entry( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_action(registries, (), {}, args=[(cg.int32, "x")]) + assert entry.type_id is ApplyAction + assert entry.synchronous is True + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + action_id, template_arg, _ = mock_cg.new_pvariable.call_args.args + assert action_id == ID("obj_1") + assert str(template_arg) == "" + + +@pytest.mark.asyncio +async def test_apply_constants( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """Constants are immediates, strings stay in flash, absent keys emit nothing, order is kept.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ApplyField("on", "set_on", cg.bool_), + ApplyField("song", "play", cg.std_string), + ApplyField("position", "position = {}", cg.float_), + ApplyCall("publish_state()"), + ) + config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5} + await _run_apply_action(registries, fields, config) + text = _apply_lambda(mock_cg) + lines = [ + f"::{PARENT_OBJ}->set_kp(0.0f);", + f"::{PARENT_OBJ}->set_on(false);", + f'::{PARENT_OBJ}->play("a:b");', + f"::{PARENT_OBJ}->position = 0.5f;", + f"::{PARENT_OBJ}->publish_state();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + assert "set_ki" not in text + + +@pytest.mark.asyncio +async def test_apply_lambdas( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A single return reduces to a cast, anything longer is called inline with the trigger args.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ) + config = { + "kp": Lambda("return x * 2;"), + "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), + } + await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) + text = _apply_lambda(mock_cg) + assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + # The parent is global-scope qualified, so an arg named like the id cannot shadow it. + assert f"::{PARENT_OBJ}->set_kp(" in text + assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text + # Outer apply lambda and inner field lambda spell the trigger arg identically. + assert text.count("const std::remove_cvref_t & x") == 2 + assert ( + f"::{PARENT_OBJ}->set_ki([](const std::remove_cvref_t & x) -> float {{" + in text + ) + assert "}(x));" in text + + +@pytest.mark.asyncio +async def test_apply_call_keys( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A multi-key call needs all keys, is skipped with none, and errors on a partial set.""" + fields = ( + ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))), + ) + await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0}) + assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_lambda(mock_cg) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {}) + assert "set_range" not in _apply_lambda(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_action(registries, fields, {"low": 1.0}) + + +@pytest.mark.asyncio +async def test_apply_action_call_shape( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("brightness", "set_brightness", cg.float_),) + await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call") + text = _apply_lambda(mock_cg) + lines = [ + f"auto apply_call = ::{PARENT_OBJ}->make_call();", + "apply_call.set_brightness(0.5f);", + "apply_call.perform();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + + +@pytest.mark.asyncio +async def test_apply_field_nested_key_const_fn_and_type_string( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = ( + ApplyField(("vertical", "direction"), "set_direction", cg.int_), + ApplyField( + "name", + "set_name", + cg.std_string, + const_fn=lambda config, value: f"{cg.safe_exp(value)}, {len(value)}", + ), + ApplyField("value", "value() = {}", "decltype({parent}->value())"), + ) + config = { + "vertical": {"direction": 3}, + "name": "abc", + "value": Lambda("return 42;"), + } + await _run_apply_action(registries, fields, config) + text = _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->set_direction(3);" in text + assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text + assert ( + f"::{PARENT_OBJ}->value() = static_castvalue())>(42);" + in text + ) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields[:1], {}) + assert "set_direction" not in _apply_lambda(mock_cg) + + +def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: + with pytest.raises(ValueError, match="2 placeholder"): + ApplyCall("set_range({}, {})", (("low", cg.float_),)) + with pytest.raises(ValueError, match="only bare"): + ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)) + ApplyCall("set_flags({{{}}})", (("flags", cg.int_),)) + with pytest.raises(ValueError, match="each arg is"): + ApplyCall("set_kp({})", (("kp", cg.float_, None, "extra"),)) + schema = cv.Schema({cv.Required(CONF_ID): cv.string, cv.Optional("kp"): cv.float_}) + register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) + register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) + for wrapped in ( + maybe_simple_id(schema), + maybe_simple_id(schema.schema), + cv.All(schema), + cv.maybe_simple_value(schema, key="kp"), + ): + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action( + "my.bad", wrapped, ApplyField("kd", "set_kd", cg.float_) + ) + nested = cv.Schema({cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_})}) + register_apply_action( + "my.nested", nested, ApplyField(("v", "dir"), "set_dir", cg.int_) + ) + with pytest.raises(ValueError, match="'dri' is not in the schema"): + register_apply_action( + "my.bad2", nested, ApplyField(("v", "dri"), "set_dir", cg.int_) + ) + + +@pytest.mark.asyncio +async def test_apply_string_constant_stays_in_flash_on_esp8266( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("song", "play", cg.std_string),) + await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266") + assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( + mock_cg + ) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 052513ce97..a618ad50e0 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -251,6 +251,15 @@ class TestCallLambda: assert isinstance(result, cg.StaticCastExpression) assert str(result) == "static_cast(foo + 1)" + def test_call_lambda__return_with_trailing_statements_is_called(self) -> None: + """Only a lone return statement reduces; a longer body is called as is.""" + lamb = cg.LambdaExpression(("return 1;\nfoo();",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result).endswith("}()") + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): """A class return type is not cast, since static_cast doesn't apply to arbitrary class types.""" From 00fa63ea19e17f149af5041f1ad742355a982e29 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:14:57 -0300 Subject: [PATCH 221/316] [climate_ir] Only advertise HEAT_COOL when heat and cool are both supported (#19483) --- esphome/components/climate_ir/__init__.py | 12 +++- esphome/components/climate_ir/climate_ir.cpp | 9 ++- esphome/components/climate_ir/climate_ir.h | 3 + tests/component_tests/climate_ir/__init__.py | 0 .../climate_ir/config/cool_only.yaml | 15 +++++ .../config/cool_only_override_on.yaml | 16 ++++++ .../climate_ir/config/heat_and_cool.yaml | 14 +++++ .../config/heat_and_cool_override_off.yaml | 15 +++++ .../climate_ir/config/heat_only.yaml | 15 +++++ .../climate_ir/config/neither.yaml | 16 ++++++ .../climate_ir/test_supports_heat_cool.py | 53 ++++++++++++++++++ tests/components/climate_ir/__init__.py | 6 ++ .../components/climate_ir/climate_ir_test.cpp | 56 +++++++++++++++++++ ...alidate-cool-only-heat-cool.esp32-idf.yaml | 10 ++++ .../gree/validate-no-cool.esp32-idf.yaml | 9 +++ .../gree/validate-no-heat-cool.esp32-idf.yaml | 9 +++ .../gree/validate-no-heat.esp32-idf.yaml | 9 +++ 17 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/climate_ir/__init__.py create mode 100644 tests/component_tests/climate_ir/config/cool_only.yaml create mode 100644 tests/component_tests/climate_ir/config/cool_only_override_on.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_and_cool.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_only.yaml create mode 100644 tests/component_tests/climate_ir/config/neither.yaml create mode 100644 tests/component_tests/climate_ir/test_supports_heat_cool.py create mode 100644 tests/components/climate_ir/__init__.py create mode 100644 tests/components/climate_ir/climate_ir_test.cpp create mode 100644 tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-heat-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-heat.esp32-idf.yaml diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 0667bd91a2..f5a31b31c4 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -14,6 +14,8 @@ from esphome.types import ConfigType, SafeExpType _LOGGER = logging.getLogger(__name__) +CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" + DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] @@ -37,6 +39,7 @@ def climate_ir_schema( { cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_HEAT_COOL): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } @@ -61,8 +64,13 @@ def climate_ir_with_receiver_schema( async def register_climate_ir(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_transmittable(var, config) - cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) - cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + supports_cool = config[CONF_SUPPORTS_COOL] + supports_heat = config[CONF_SUPPORTS_HEAT] + cg.add(var.set_supports_cool(supports_cool)) + cg.add(var.set_supports_heat(supports_heat)) + # The header default is true, so only the false case needs a call. + if not config.get(CONF_SUPPORTS_HEAT_COOL, supports_cool and supports_heat): + cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) if sensor_id := config.get(CONF_SENSOR): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index a8edaae6ea..ca639c7de0 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,11 +13,13 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); if (this->supports_heat_) traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); + if (this->supports_heat_cool_) + traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); if (this->supports_dry_) traits.add_supported_mode(climate::CLIMATE_MODE_DRY); if (this->supports_fan_only_) @@ -94,9 +96,10 @@ void ClimateIR::dump_config() { " Min. Temperature: %.1f°C\n" " Max. Temperature: %.1f°C\n" " Supports HEAT: %s\n" - " Supports COOL: %s", + " Supports COOL: %s\n" + " Supports HEAT_COOL: %s", this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_)); + YESNO(this->supports_cool_), YESNO(this->supports_heat_cool_)); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 6c49b31030..d65ed5d621 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -41,6 +41,7 @@ class ClimateIR : public Component, void dump_config() override; void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -60,6 +61,8 @@ class ClimateIR : public Component, bool supports_cool_{true}; bool supports_heat_{true}; + // Default (supports_cool && supports_heat) is resolved during code generation. + bool supports_heat_cool_{true}; bool supports_dry_{false}; bool supports_fan_only_{false}; climate::ClimateFanModeMask fan_modes_{}; diff --git a/tests/component_tests/climate_ir/__init__.py b/tests/component_tests/climate_ir/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/climate_ir/config/cool_only.yaml b/tests/component_tests/climate_ir/config/cool_only.yaml new file mode 100644 index 0000000000..e40efc811a --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false diff --git a/tests/component_tests/climate_ir/config/cool_only_override_on.yaml b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml new file mode 100644 index 0000000000..82c2d7c881 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_heat_cool: true diff --git a/tests/component_tests/climate_ir/config/heat_and_cool.yaml b/tests/component_tests/climate_ir/config/heat_and_cool.yaml new file mode 100644 index 0000000000..d92c2ade39 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool.yaml @@ -0,0 +1,14 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix diff --git a/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml new file mode 100644 index 0000000000..6a72a5981e --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat_cool: false diff --git a/tests/component_tests/climate_ir/config/heat_only.yaml b/tests/component_tests/climate_ir/config/heat_only.yaml new file mode 100644 index 0000000000..c87a3820b7 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_cool: false diff --git a/tests/component_tests/climate_ir/config/neither.yaml b/tests/component_tests/climate_ir/config/neither.yaml new file mode 100644 index 0000000000..78830a0a9b --- /dev/null +++ b/tests/component_tests/climate_ir/config/neither.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_cool: false diff --git a/tests/component_tests/climate_ir/test_supports_heat_cool.py b/tests/component_tests/climate_ir/test_supports_heat_cool.py new file mode 100644 index 0000000000..7ff9381cd6 --- /dev/null +++ b/tests/component_tests/climate_ir/test_supports_heat_cool.py @@ -0,0 +1,53 @@ +"""Tests for the supports_heat_cool default resolved in climate_ir code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _emitted_value(main_cpp: str) -> str | None: + """Return the argument of the generated set_supports_heat_cool() call, or None if absent.""" + match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp) + return match.group(1) if match else None + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("heat_and_cool.yaml", None), + ("cool_only.yaml", "false"), + ("heat_only.yaml", "false"), + ("neither.yaml", "false"), + ], +) +def test_default_requires_heat_and_cool( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without the key, HEAT_COOL follows supports_heat and supports_cool.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("cool_only_override_on.yaml", None), + ("heat_and_cool_override_off.yaml", "false"), + ], +) +def test_explicit_key_overrides_default( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected diff --git a/tests/components/climate_ir/__init__.py b/tests/components/climate_ir/__init__.py new file mode 100644 index 0000000000..ae68ae228a --- /dev/null +++ b/tests/components/climate_ir/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ClimateIR derives from climate::Climate without declaring it as a dependency. + manifest.dependencies = manifest.dependencies + ["climate"] diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp new file mode 100644 index 0000000000..479e1f1b45 --- /dev/null +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -0,0 +1,56 @@ +#include +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::climate_ir::testing { + +class TestClimateIR : public ClimateIR { + public: + TestClimateIR() : ClimateIR(16.0f, 30.0f) {} + + using ClimateIR::traits; + + protected: + void transmit_state() override {} +}; + +// The HEAT_COOL default is covered in tests/component_tests/climate_ir. + +TEST(ClimateIRTest, HeatCoolAdvertisedWhenSupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolNotAdvertisedWhenUnsupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolAdvertisedForCoolOnlyDeviceThatSupportsIt) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); +} + +} // namespace esphome::climate_ir::testing diff --git a/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml new file mode 100644 index 0000000000..9b17da7b9b --- /dev/null +++ b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml @@ -0,0 +1,10 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false + supports_heat_cool: true diff --git a/tests/components/gree/validate-no-cool.esp32-idf.yaml b/tests/components/gree/validate-no-cool.esp32-idf.yaml new file mode 100644 index 0000000000..3862f302de --- /dev/null +++ b/tests/components/gree/validate-no-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_cool: false diff --git a/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml new file mode 100644 index 0000000000..eea670a696 --- /dev/null +++ b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat_cool: false diff --git a/tests/components/gree/validate-no-heat.esp32-idf.yaml b/tests/components/gree/validate-no-heat.esp32-idf.yaml new file mode 100644 index 0000000000..3c042ebe69 --- /dev/null +++ b/tests/components/gree/validate-no-heat.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false From c609db16b3fa48e410523ffde6b623b1e3fb8846 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:28:32 -0300 Subject: [PATCH 222/316] [gree] Store the model in one byte (#19488) Co-authored-by: J. Nick Koston --- esphome/components/gree/gree.cpp | 22 +++++++++++----------- esphome/components/gree/gree.h | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index a794e7721f..634a2d33b5 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -42,23 +42,24 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) { void GreeClimate::transmit_state() { uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; + const Model model = this->model_; remote_state[0] = this->fan_speed_() | this->operation_mode_(); remote_state[1] = this->temperature_(); - if (this->model_ == GREE_YAN) { + if (model == GREE_YAN) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { + if (model == GREE_YX1FF || model == GREE_YAG) { remote_state[2] = 0x60; remote_state[3] = 0x50; remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YAG) { + if (model == GREE_YAG) { remote_state[5] = 0x40; if (this->vertical_swing_() == GREE_VDIR_SWING || this->horizontal_swing_() == GREE_HDIR_SWING) { @@ -66,11 +67,11 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAC || this->model_ == GREE_YAG) { + if (model == GREE_YAC || model == GREE_YAG) { remote_state[4] |= (this->horizontal_swing_() << 4); } - if (this->model_ == GREE_YAA || this->model_ == GREE_YAC || this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 @@ -82,14 +83,13 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAN || this->model_ == GREE_YAA || this->model_ == GREE_YAC || - this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAN || model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { // Merge the mode bits into remote_state[2] // Clear the mode bits (bits 4-7) and OR in the current mode_bits_ remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_; } - if (this->model_ == GREE_YX1FF) { + if (model == GREE_YX1FF) { if (this->fan_speed_() == GREE_FAN_TURBO) { remote_state[2] |= GREE_FAN_TURBO_BIT; } @@ -100,7 +100,7 @@ void GreeClimate::transmit_state() { } // Calculate the checksum - if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF) { + if (model == GREE_YAN || model == GREE_YX1FF) { remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0); } else { remote_state[7] = @@ -115,7 +115,7 @@ void GreeClimate::transmit_state() { data->set_carrier_frequency(GREE_IR_FREQUENCY); data->mark(GREE_HEADER_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_HEADER_SPACE); } else { data->space(GREE_HEADER_SPACE); @@ -137,7 +137,7 @@ void GreeClimate::transmit_state() { data->space(GREE_ZERO_SPACE); data->mark(GREE_BIT_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_MESSAGE_SPACE); } else { data->space(GREE_MESSAGE_SPACE); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 2f10be3e6b..2f87926942 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -77,7 +77,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes -enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; +enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; class GreeClimate final : public climate_ir::ClimateIR { public: From a4bebc308377a2221c914869a34b44ad023bcda5 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:29:11 -0300 Subject: [PATCH 223/316] [mitsubishi] Store the fan mode and default directions in one byte each (#19489) Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/mitsubishi.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 7925b7ce44..657da900e2 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -11,7 +11,7 @@ const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius // Fan mode -enum SetFanMode { +enum SetFanMode : uint8_t { MITSUBISHI_FAN_3L = 0, // 3 levels + auto MITSUBISHI_FAN_4L, // 4 levels + auto MITSUBISHI_FAN_Q4L, // Quiet + 4 levels + auto @@ -19,7 +19,7 @@ enum SetFanMode { }; // Enum to represent horizontal directios -enum HorizontalDirection { +enum HorizontalDirection : uint8_t { HORIZONTAL_DIRECTION_LEFT = 0x10, HORIZONTAL_DIRECTION_MIDDLE_LEFT = 0x20, HORIZONTAL_DIRECTION_MIDDLE = 0x30, @@ -29,7 +29,7 @@ enum HorizontalDirection { }; // Enum to represent vertical directions -enum VerticalDirection { +enum VerticalDirection : uint8_t { VERTICAL_DIRECTION_AUTO = 0x00, VERTICAL_DIRECTION_UP = 0x08, VERTICAL_DIRECTION_MIDDLE_UP = 0x10, From ca46954335834755602b9394e87d1887f2212753 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 08:03:59 -0300 Subject: [PATCH 224/316] [fujitsu_general] Read the mode, fan speed and swing fields with their real width (#18297) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../fujitsu_general/fujitsu_general.cpp | 161 ++++++----- .../fujitsu_general/fujitsu_general.h | 27 ++ tests/components/fujitsu_general/__init__.py | 9 + .../fujitsu_general/fujitsu_general_test.cpp | 254 ++++++++++++++++++ 4 files changed, 366 insertions(+), 85 deletions(-) create mode 100644 tests/components/fujitsu_general/__init__.py create mode 100644 tests/components/fujitsu_general/fujitsu_general_test.cpp diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index aca643defe..9995a3abbd 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,12 +2,6 @@ namespace esphome::fujitsu_general { -// bytes' bits are reversed for fujitsu, so nibbles are ordered 1, 0, 3, 2, 5, 4, etc... - -#define SET_NIBBLE(message, nibble, value) \ - ((message)[(nibble) / 2] |= ((value) &0b00001111) << (((nibble) % 2) ? 0 : 4)) -#define GET_NIBBLE(message, nibble) (((message)[(nibble) / 2] >> (((nibble) % 2) ? 0 : 4)) & 0b00001111) - static const char *const TAG = "fujitsu_general.climate"; // Common header @@ -36,32 +30,29 @@ constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; -// Temperature -constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; - // Power on -constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +// Bit 3 is the clean flag, which is also 10 degree heat on the ARRAH2E and ARREW4E remotes. +constexpr uint8_t FUJITSU_GENERAL_MODE_MASK = 0b0111; +constexpr uint8_t FUJITSU_GENERAL_CLEAN_BIT = 0b1000; constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; -// const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; // Swing -constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_SWING_MASK = 0b0011; constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; +constexpr uint8_t FUJITSU_GENERAL_FAN_MASK = 0b0111; constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; @@ -111,68 +102,67 @@ void FujitsuGeneralClimate::transmit_state() { uint8_t temperature_clamped = (uint8_t) roundf(clamp(this->target_temperature, FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX)); uint8_t temperature_offset = temperature_clamped - FUJITSU_GENERAL_TEMP_MIN; - SET_NIBBLE(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); + set_nibble(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); // Set power on if (!this->power_) { - SET_NIBBLE(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); + set_nibble(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); } // Set mode switch (this->mode) { case climate::CLIMATE_MODE_COOL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); break; case climate::CLIMATE_MODE_HEAT: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); break; case climate::CLIMATE_MODE_DRY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); break; case climate::CLIMATE_MODE_FAN_ONLY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); break; case climate::CLIMATE_MODE_HEAT_COOL: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); break; - // TODO: CLIMATE_MODE_10C is missing from esphome } // Set fan switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); break; case climate::CLIMATE_FAN_MEDIUM: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); break; case climate::CLIMATE_FAN_LOW: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); break; case climate::CLIMATE_FAN_QUIET: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); break; case climate::CLIMATE_FAN_AUTO: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); break; } // Set swing switch (this->swing_mode) { case climate::CLIMATE_SWING_VERTICAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); break; case climate::CLIMATE_SWING_HORIZONTAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); break; case climate::CLIMATE_SWING_BOTH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); break; case climate::CLIMATE_SWING_OFF: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); break; } @@ -243,6 +233,52 @@ uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } +// These decoders use if chains rather than switches: on ESP8266 the compiler turns a dense switch +// into a lookup table in .rodata, which lives in RAM there. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode) { + const uint8_t mode = mode_field & FUJITSU_GENERAL_MODE_MASK; + if (mode == FUJITSU_GENERAL_MODE_COOL) + return climate::CLIMATE_MODE_COOL; + if (mode == FUJITSU_GENERAL_MODE_HEAT) + return climate::CLIMATE_MODE_HEAT; + if (mode == FUJITSU_GENERAL_MODE_DRY) + return climate::CLIMATE_MODE_DRY; + if (mode == FUJITSU_GENERAL_MODE_FAN) + return climate::CLIMATE_MODE_FAN_ONLY; + if (mode == FUJITSU_GENERAL_MODE_AUTO) + return climate::CLIMATE_MODE_HEAT_COOL; + // A state frame means the unit is on, so never keep OFF. + ESP_LOGW(TAG, "Received unassigned mode %X, keeping the current mode", mode); + return current_mode == climate::CLIMATE_MODE_OFF ? climate::CLIMATE_MODE_HEAT_COOL : current_mode; +} + +optional decode_fan_mode(uint8_t fan_field, optional current_mode) { + const uint8_t fan = fan_field & FUJITSU_GENERAL_FAN_MASK; + if (fan == FUJITSU_GENERAL_FAN_HIGH) + return climate::CLIMATE_FAN_HIGH; + if (fan == FUJITSU_GENERAL_FAN_MEDIUM) + return climate::CLIMATE_FAN_MEDIUM; + if (fan == FUJITSU_GENERAL_FAN_LOW) + return climate::CLIMATE_FAN_LOW; + if (fan == FUJITSU_GENERAL_FAN_SILENT) + return climate::CLIMATE_FAN_QUIET; + if (fan == FUJITSU_GENERAL_FAN_AUTO) + return climate::CLIMATE_FAN_AUTO; + ESP_LOGW(TAG, "Received unassigned fan speed %X, keeping the current fan mode", fan); + return current_mode; +} + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field) { + const uint8_t swing = swing_field & FUJITSU_GENERAL_SWING_MASK; + if (swing == FUJITSU_GENERAL_SWING_VERTICAL) + return climate::CLIMATE_SWING_VERTICAL; + if (swing == FUJITSU_GENERAL_SWING_HORIZONTAL) + return climate::CLIMATE_SWING_HORIZONTAL; + if (swing == FUJITSU_GENERAL_SWING_BOTH) + return climate::CLIMATE_SWING_BOTH; + return climate::CLIMATE_SWING_OFF; +} + bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { ESP_LOGV(TAG, "Received IR message"); @@ -316,70 +352,25 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { } if (recv_message_type == FUJITSU_GENERAL_MESSAGE_TYPE_STATE) { - const uint8_t recv_tempertature = GET_NIBBLE(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); + const uint8_t recv_tempertature = get_nibble(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); const uint8_t offset_temperature = recv_tempertature + FUJITSU_GENERAL_TEMP_MIN; this->target_temperature = offset_temperature; ESP_LOGV(TAG, "Received temperature %d", offset_temperature); - const uint8_t recv_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); + const uint8_t recv_mode = get_nibble(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); ESP_LOGV(TAG, "Received mode %X", recv_mode); - switch (recv_mode) { - case FUJITSU_GENERAL_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - case FUJITSU_GENERAL_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case FUJITSU_GENERAL_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case FUJITSU_GENERAL_MODE_FAN: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case FUJITSU_GENERAL_MODE_AUTO: - default: - // TODO: CLIMATE_MODE_10C is missing from esphome - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; + if ((recv_mode & FUJITSU_GENERAL_CLEAN_BIT) != 0) { + ESP_LOGW(TAG, "Received a frame with the clean / 10 degree heat bit set, which is not supported"); } + this->mode = decode_mode(recv_mode, this->mode); - const uint8_t recv_fan_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); + const uint8_t recv_fan_mode = get_nibble(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); ESP_LOGV(TAG, "Received fan mode %X", recv_fan_mode); - switch (recv_fan_mode) { - case FUJITSU_GENERAL_FAN_SILENT: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - case FUJITSU_GENERAL_FAN_LOW: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - case FUJITSU_GENERAL_FAN_MEDIUM: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - case FUJITSU_GENERAL_FAN_HIGH: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - case FUJITSU_GENERAL_FAN_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } + this->fan_mode = decode_fan_mode(recv_fan_mode, this->fan_mode); - const uint8_t recv_swing_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); + const uint8_t recv_swing_mode = get_nibble(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); ESP_LOGV(TAG, "Received swing mode %X", recv_swing_mode); - switch (recv_swing_mode) { - case FUJITSU_GENERAL_SWING_VERTICAL: - this->swing_mode = climate::CLIMATE_SWING_VERTICAL; - break; - case FUJITSU_GENERAL_SWING_HORIZONTAL: - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - break; - case FUJITSU_GENERAL_SWING_BOTH: - this->swing_mode = climate::CLIMATE_SWING_BOTH; - break; - case FUJITSU_GENERAL_SWING_NONE: - default: - this->swing_mode = climate::CLIMATE_SWING_OFF; - } + this->swing_mode = decode_swing_mode(recv_swing_mode); this->power_ = true; } diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 93363860d4..20bda883b2 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -43,9 +43,36 @@ constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius * heat 30 swing vert 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00101000 00000000 00000000 00000000 00000100 00011101 * heat 30 noswing 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00100000 00000000 00000000 00000000 00000100 00010011 * ``` + * + * The column markers show which bits varied in these captures, not field widths. */ // clang-format on +// Bits are reversed within each byte, so an odd nibble index is the low half of its byte. +constexpr uint8_t get_nibble(const uint8_t *message, uint8_t nibble) { + return (message[nibble / 2] >> ((nibble % 2) ? 0 : 4)) & 0b00001111; +} + +/// Write a nibble into a zero-initialised frame. +constexpr void set_nibble(uint8_t *message, uint8_t nibble, uint8_t value) { + message[nibble / 2] |= (value & 0b00001111) << ((nibble % 2) ? 0 : 4); +} + +// Nibble indices of the state frame fields. +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; + +/// Unassigned values keep the current mode, except that OFF becomes HEAT_COOL. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode); + +/// Unassigned values keep the current fan mode. +optional decode_fan_mode(uint8_t fan_field, optional current_mode); + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field); + class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() diff --git a/tests/components/fujitsu_general/__init__.py b/tests/components/fujitsu_general/__init__.py new file mode 100644 index 0000000000..4ade799826 --- /dev/null +++ b/tests/components/fujitsu_general/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # This component's AUTO_LOAD = ["climate_ir"] sits on the climate platform manifest, while its + # own __init__.py is empty. The unit test build resolves the bare `fujitsu_general` domain, so + # it never sees that manifest. And climate_ir itself doesn't declare `climate` even though + # ClimateIR derives from climate::Climate. Pull both in so the test can include the header. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/fujitsu_general/fujitsu_general_test.cpp b/tests/components/fujitsu_general/fujitsu_general_test.cpp new file mode 100644 index 0000000000..65b204131a --- /dev/null +++ b/tests/components/fujitsu_general/fujitsu_general_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/fujitsu_general/fujitsu_general.h" + +namespace esphome::fujitsu_general::testing { + +// The mode field of a received frame is three bits wide. The fourth bit of the same nibble belongs +// to the clean feature, so it has to be ignored when reading the mode. + +TEST(FujitsuGeneralDecodeModeTest, DecodesTheAssignedModes) { + EXPECT_EQ(decode_mode(0x00, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x01, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x02, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x03, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_EQ(decode_mode(0x04, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, IgnoresTheCleanBit) { + // 0x0B is fan mode with the clean bit set. It used to be read as one value and reported as + // heat/cool, which is the bug this covers. + EXPECT_EQ(decode_mode(0x0B, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + + EXPECT_EQ(decode_mode(0x08, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x09, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0A, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x0C, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, KeepsTheCurrentModeForUnassignedValues) { + // 0x5 to 0x7 fit in the field but the protocol does not use them. + EXPECT_EQ(decode_mode(0x05, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x06, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x07, climate::CLIMATE_MODE_DRY), climate::CLIMATE_MODE_DRY); + + // The same three with the clean bit set. Without the mask these would not reach this branch. + EXPECT_EQ(decode_mode(0x0D, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0E, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x0F, climate::CLIMATE_MODE_FAN_ONLY), climate::CLIMATE_MODE_FAN_ONLY); +} + +TEST(FujitsuGeneralDecodeModeTest, NeverReportsOffForAStateFrame) { + // A state frame describes a running unit, so keeping an off current mode would publish it as off + // and turn the next transmission into a power off command. Automatic is the least specific mode + // available, which is what the field's unassigned values decoded to before they were masked. + for (uint8_t field = 0x05; field <= 0x07; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_mode(field, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(field | 0b1000, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + } +} + +// The fan speed field is three bits wide as well, and used to fold every value it did not +// recognise into the automatic speed. + +TEST(FujitsuGeneralDecodeFanModeTest, DecodesTheAssignedSpeeds) { + EXPECT_EQ(decode_fan_mode(0x00, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x01, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x02, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x03, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x04, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, IgnoresTheFourthBit) { + EXPECT_EQ(decode_fan_mode(0x08, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x09, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0A, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x0B, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0C, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, KeepsTheCurrentFanModeForUnassignedValues) { + EXPECT_EQ(decode_fan_mode(0x05, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x06, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x07, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0D, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0E, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0F, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); +} + +TEST(FujitsuGeneralDecodeFanModeTest, LeavesAnUnsetFanModeUnset) { + EXPECT_FALSE(decode_fan_mode(0x05, {}).has_value()); +} + +// The swing field is only two bits wide. The two bits above it are reserved, and were read as part +// of the value. + +TEST(FujitsuGeneralDecodeSwingModeTest, DecodesTheAssignedValues) { + EXPECT_EQ(decode_swing_mode(0x00), climate::CLIMATE_SWING_OFF); + EXPECT_EQ(decode_swing_mode(0x01), climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(decode_swing_mode(0x02), climate::CLIMATE_SWING_HORIZONTAL); + EXPECT_EQ(decode_swing_mode(0x03), climate::CLIMATE_SWING_BOTH); +} + +TEST(FujitsuGeneralDecodeSwingModeTest, IgnoresTheReservedBits) { + // Without the mask everything from 0x04 up fell through to the default branch and reported swing + // off. All twelve are covered, so the field's whole input space is asserted. + const climate::ClimateSwingMode expected[] = {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}; + for (uint8_t field = 0x04; field <= 0x0F; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_swing_mode(field), expected[field & 0b0011]); + } +} + +// Every state frame annotated in fujitsu_general.h, as the bytes those rows spell out. None of them +// sets the fourth bit of the mode or fan field, or either bit above the swing field, so the masks +// must leave all of them decoding exactly as they did before this change. + +namespace { + +struct CapturedFrame { + const char *label; + uint8_t bytes[16]; + uint8_t temperature; + bool turn_on; + climate::ClimateMode mode; + climate::ClimateFanMode fan_mode; + climate::ClimateSwingMode swing_mode; +}; + +constexpr CapturedFrame CAPTURED_FRAMES[] = { + {"auto auto 18", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x8F}, + 18, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 19", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7F}, + 19, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (temperatures)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"on at 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAB}, + 16, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"down to 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAC}, + 16, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (mode options)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"cool auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCE}, + 30, + true, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"dry auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x02, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCD}, + 30, + true, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"fan (auto) (30)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x03, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCC}, + 30, + true, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCB}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat 30 high", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_SWING_OFF}, + {"heat 30 med", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x02, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_OFF}, + {"heat 30 low", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x03, 0x00, 0x00, 0x00, 0x20, 0xC9}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_OFF}, + {"heat 30 quiet", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, + {"heat 30 swing vert", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x14, 0x00, 0x00, 0x00, 0x20, 0xB8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_VERTICAL}, + {"heat 30 noswing", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, +}; + +} // namespace + +TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) { + for (const auto &frame : CAPTURED_FRAMES) { + SCOPED_TRACE(frame.label); + // Read through the component's own nibble helper and field indices, so this also fails if the + // frame layout the header records ever stops matching what on_receive() reads. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_TEMPERATURE_NIBBLE) + FUJITSU_GENERAL_TEMP_MIN, + frame.temperature); + // The turn on flag is only written by transmit_state(), so this pins the frame layout rather + // than a decode path. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_POWER_ON_NIBBLE) != 0, frame.turn_on); + EXPECT_EQ(decode_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_MODE_NIBBLE), climate::CLIMATE_MODE_OFF), frame.mode); + EXPECT_EQ(decode_fan_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_FAN_NIBBLE), climate::CLIMATE_FAN_ON), + frame.fan_mode); + EXPECT_EQ(decode_swing_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_SWING_NIBBLE)), frame.swing_mode); + } +} + +} // namespace esphome::fujitsu_general::testing From 8c3ebd3414fc3586c621ba77be7146dd44a6755a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:14:04 +0100 Subject: [PATCH 225/316] [docker] Drop the unused PlatformIO library preinstall (#19504) Co-authored-by: esphbot <287758279+esphbot@users.noreply.github.com> --- .devcontainer/Dockerfile | 3 - .github/workflows/ci-docker.yml | 2 - docker/Dockerfile | 3 - docker/test_configs/bk72xx-arduino.yaml | 12 + script/platformio_install_deps.py | 377 ----------- tests/script/test_platformio_install_deps.py | 649 ------------------- 6 files changed, 12 insertions(+), 1034 deletions(-) delete mode 100755 script/platformio_install_deps.py delete mode 100644 tests/script/test_platformio_install_deps.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6f7e892284..8527cf491b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,7 +31,4 @@ RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 -COPY script/platformio_install_deps.py platformio.ini ./ -RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools - WORKDIR /workspaces diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index c55b5326ec..5ef51be02c 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -12,9 +12,7 @@ on: - ".github/workflows/ci-docker.yml" - "requirements*.txt" - "pyproject.toml" - - "platformio.ini" - "esphome/idf_component.yml" - - "script/platformio_install_deps.py" # Core, build pipeline, toolchain, and target-platform changes can change # how a toolchain is set up or built, so re-run the per-toolchain compile # smoke test when they change. diff --git a/docker/Dockerfile b/docker/Dockerfile index e00570c8ff..4a177ad006 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,9 +29,6 @@ RUN \ && platformio settings set check_platformio_interval 1000000 \ && mkdir -p /piolibs -COPY script/platformio_install_deps.py platformio.ini / -RUN /platformio_install_deps.py /platformio.ini --libraries - ARG BUILD_VERSION LABEL \ diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml index 138aa9e282..f05e400c53 100644 --- a/docker/test_configs/bk72xx-arduino.yaml +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -5,3 +5,15 @@ bk72xx: board: generic-bk7231n-qfn32-tuya logger: + +wifi: + ssid: MySSID + password: password1 + ap: + +# mqtt and captive_portal together pull in AsyncTCP and ESPAsyncWebServer; +# a stray ESP32 AsyncTCP copy on the library search path breaks this build +captive_portal: + +mqtt: + broker: 192.168.178.84 diff --git a/script/platformio_install_deps.py b/script/platformio_install_deps.py deleted file mode 100755 index 1c4fb28b30..0000000000 --- a/script/platformio_install_deps.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env python3 -# This script is used to preinstall -# all platformio libraries in the global storage - -import argparse -from concurrent.futures import ThreadPoolExecutor -import configparser -from contextlib import suppress -import os -from pathlib import Path -import queue -import subprocess -import threading -import traceback - -# esphome is not installed at this docker layer; pio's fs.rmtree is the -# same chmod-on-readonly shape its own installer uses -try: - from platformio import fs - from platformio.cache import ContentCache - from platformio.package.manager.base import BasePackageManager - from platformio.package.manager.library import LibraryPackageManager - from platformio.package.manager.tool import ToolPackageManager - from platformio.package.meta import PackageCompatibility - - PARALLEL_AVAILABLE = True -except ImportError as err: # pragma: no cover - # A moved pio module must degrade to the serial pass, not kill the - # image build; the tripwire test makes the drift loud in CI - PARALLEL_AVAILABLE = False - IMPORT_ERROR = repr(err) - -# Network-bound downloads release the GIL, so the pool oversubscribes -# the cores. This bypasses pio's 500ms registry throttle and races its -# self-unlinking cache LockFiles; both are cache-only and self-healing. -MAX_WORKERS = 16 - - -class CleanupError(RuntimeError): - """A torn destination could not be removed; the serial pass would - trust it, so the build must fail rather than bake a corrupt image.""" - - -class LockReleaseError(RuntimeError): - """The manager lock could not be released; the serial pass would - block on it, so the build must fail with the cause named.""" - - -def parse_specs(path: str, args: argparse.Namespace) -> tuple[list, list, list]: - """Extract lib/platform/tool specs from every section of a platformio.ini.""" - config = configparser.ConfigParser(inline_comment_prefixes=(";",)) - if not config.read(path): - # ConfigParser silently ignores unreadable files; an empty spec - # list would build an image with no dependencies at all - raise SystemExit(f"Could not read {path}") - libs = [] - tools = [] - platforms = [] - for section in config.sections(): - conf = config[section] - if "lib_deps" in conf and args.libraries: - for lib_dep in conf["lib_deps"].splitlines(): - if not lib_dep: - # Empty line or comment - continue - if lib_dep.startswith("${"): - # Extending from another section - continue - if "@" not in lib_dep: - # No version pinned, this is an internal lib - continue - libs.append(lib_dep) - if "platform" in conf and args.platforms: - platforms.append(conf["platform"]) - if "platform_packages" in conf and args.tools: - for tool in conf["platform_packages"].splitlines(): - if not tool: - # Empty line or comment - continue - if tool.startswith("${"): - # Extending from another section - continue - if tool.find("https://github.com") != -1: - split = tool.find("@") - tool = tool[split + 1 :] - tools.append(tool) - # Exact-string dedupe only: name-level dedupe would change which - # version conflicts the pkg install pass reconciles - return ( - list(dict.fromkeys(libs)), - list(dict.fromkeys(platforms)), - list(dict.fromkeys(tools)), - ) - - -def piopm_matches(package_dir: str, spec) -> list[Path]: - """Dirs whose .piopm metadata names this spec; a positive match beats - guessing the manifest-derived dirname from the registry name.""" - want = (BasePackageManager.ensure_spec(spec).name or "").lower() - matches: list[Path] = [] - if not want: - return matches - try: - entries = list(Path(package_dir).iterdir()) - except FileNotFoundError: - return matches - for d in entries: - if not d.is_dir(): - continue # pio's get_installed skips files and *.pio-link too - try: - meta = fs.load_json(str(d / ".piopm")) - except FileNotFoundError: - continue # no metadata means pio does not trust it either - except (OSError, ValueError): - if d.name.lower() == want: - # A corrupt .piopm under this spec's own name would crash - # pio's whole storage scan; remove it - matches.append(d) - continue - mspec = meta.get("spec") or {} - if (mspec.get("name") or meta.get("name") or "").lower() == want: - matches.append(d) - return matches - - -def remove_dir(spec, dest: Path) -> None: - # fs.rmtree never raises (errors go to a printing onexc handler); - # only the destination's absence proves the cleanup worked - fs.rmtree(str(dest)) - if dest.exists(): - # Failing the build beats baking a corrupt image - raise CleanupError( - f"could not remove the failed pre-install of {spec} at {dest}" - ) - print(f"Removed torn destination {dest}", flush=True) - - -def cleanup_or_die(mgr, spec) -> None: - """Cleanup that did not demonstrably succeed must fail the build.""" - try: - clean_torn(mgr, spec) - except CleanupError: - raise - except Exception as err: # noqa: BLE001 - raise CleanupError(f"cleanup failed for {spec}: {err!r}") from err - - -def clean_torn(mgr, spec) -> None: - """Remove a torn destination so the serial pass cannot trust it.""" - pkg = None - with suppress(Exception): - # get_package memoizes a pre-install snapshot; reset to see the - # torn dir. It also recognizes manifest-only legacy dirs pio's - # storage scan would trust, which the .piopm fallback cannot see. - mgr.memcache_reset() - pkg = mgr.get_package(spec) - if pkg is not None: - remove_dir(spec, Path(pkg.path)) - elif dests := piopm_matches(mgr.package_dir, spec): - # A .piopm naming this spec is the exact shape the serial pass - # trusts; a dir without one is overwritten by pio's own install - for dest in dests: - remove_dir(spec, dest) - else: - print(f"No resolvable destination to clean for {spec}", flush=True) - - -def spec_key(spec) -> str | None: - """The destination identity of a spec: PlatformIO installs by package - name, so two specs sharing a name share a directory. ``None`` means - the name could not be derived; such a spec must stay out of the wave - (a raw-string key would break the one-per-destination guarantee).""" - name = BasePackageManager.ensure_spec(spec).name - return name.lower() if name else None - - -def dependency_specs(manager, specs: list) -> list: - """``(spec, compatibility)`` registry dependencies of installed - packages, from local manifest reads. Name-only dependencies - (platform-bundled libs like SPI) stay with the ``pkg install`` pass; - the compatibility qualifiers mirror pio's install_dependency, so a - qualified dep resolves to the same package the serial pass picks.""" - return [ - (manager.dependency_to_spec(dep), PackageCompatibility.from_dependency(dep)) - for spec in specs - if (pkg := manager.get_package(spec)) is not None - for dep in manager.get_pkg_dependencies(pkg) or [] - if dep.get("owner") or dep.get("version") - ] - - -def parallel_install(manager_cls, specs: list, prior_names: set | None = None) -> None: - """Best-effort parallel top-level install. - - PlatformIO's own installer downloads and unpacks one package at a time - on one core. Dependencies are skipped (two packages sharing one must - not extract into the same directory from two threads) and failures are - only reported: the stock ``pkg install`` pass afterwards installs - whatever is missing and is the authority on the final state. - """ - if not specs: - return - manager = manager_cls(None) - # One spec per destination: two threads must not extract into the - # same directory. Second versions of a name and URL specs (their dir - # comes from the archive manifest) stay with the pkg install pass. - seen_names: set = prior_names if prior_names is not None else set() - # Wave-1 items are strings; dependency waves carry (spec, compatibility) - pairs = [item if isinstance(item, tuple) else (item, None) for item in specs] - unique = {} - for spec, compat in pairs: - # Normalize once: a dependency's URL version surfaces as spec.uri - parsed = BasePackageManager.ensure_spec(spec) - if parsed.uri: - continue - if (key := spec_key(parsed)) is None: - # No name, no destination identity; leave it to the serial pass - print(f"Skipping unresolvable spec {spec!r} in the wave", flush=True) - continue - unique.setdefault(key, (spec, compat)) # first-wins, like pio's walk - pending = [ - (spec, compat) - for spec, compat in unique.values() - if not manager.get_package(spec) - ] - if not pending: - # Nothing to install, but a warm store's dependencies must still - # feed the next wave (a transitive dep may be missing) - _next_wave(manager_cls, manager, unique, seen_names) - return - workers = min(len(pending), MAX_WORKERS) - # One manager per worker (_install mutates instance state); built - # serially because construction rewires the shared manager logger - managers: queue.SimpleQueue = queue.SimpleQueue() - for _ in range(workers): - managers.put(manager_cls(None)) - local = threading.local() - - def install_one(item) -> bool: - spec, compat = item - if (mgr := getattr(local, "mgr", None)) is None: - mgr = local.mgr = managers.get_nowait() - try: - mgr._install( # noqa: SLF001 - spec, skip_dependencies=True, compatibility=compat - ) - return True - except Exception as err: # noqa: BLE001 - print(f"Pre-install of {spec} failed ({err!r})", flush=True) - cleanup_or_die(mgr, spec) - return False - except BaseException: - # A worker SystemExit (main() guards against it) must not skip - # the cleanup and leave a torn dir the serial pass trusts - cleanup_or_die(mgr, spec) - raise - - print(f"Preinstalling {len(pending)} package(s) with {workers} workers", flush=True) - # The serial getter calls create pio's lazy dirs (made without - # exist_ok) before cold-cache workers can race the creation - manager.get_download_dir() - manager.get_tmp_dir() - ContentCache("http") - cwd = Path.cwd() - manager.lock() - try: - with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(install_one, item) for item in pending] - # The with-block joined every future; drain them all so a - # concurrent CleanupError is never dropped - errors = [err for f in futures if (err := f.exception()) is not None] - for err in errors: - # Every failure is on the record; the raised one is a summary - print(f"Wave failure: {err!r}", flush=True) - if errors: - raise next((e for e in errors if isinstance(e, CleanupError)), errors[0]) - results = [f.result() for f in futures] - finally: - try: - manager.unlock() - except Exception as unlock_err: # noqa: BLE001 - # A held flock would hang the serial pass in another process; - # failing loudly beats an unexplained stuck docker build. Any - # in-flight error stays attached as the context. - raise LockReleaseError( - f"could not release the manager lock: {unlock_err!r}" - ) from unlock_err - # Worker postinstall scripts chdir process-wide (pio's fs.cd); - # restore between waves. The serial pass pins its own cwd. - with suppress(OSError): - os.chdir(cwd) - if failures := len(results) - sum(results): - # The stock pass retries CLI specs and re-walks installed - # packages' dependencies, so failed deps retry too - print( - f"Pre-install failed for {failures} of {len(results)} package(s); " - "pkg install retries them serially", - flush=True, - ) - - # Waves skip dependencies (a shared one must not extract from two - # threads); the installed manifests feed the next wave - _next_wave(manager_cls, manager, unique, seen_names) - - -def _next_wave(manager_cls, manager, unique: dict, seen_names: set) -> None: - """Queue the dependency wave for every requested spec, installed or - freshly waved; a warm store can still be missing a transitive dep. - Terminates without a cap: each wave admits only never-seen names.""" - seen_names.update(unique) - # The pre-wave get_package calls memoized an empty storage snapshot - manager.memcache_reset() - next_specs = [ - item - for item in dependency_specs(manager, [spec for spec, _ in unique.values()]) - if spec_key(item[0]) not in seen_names - ] - if next_specs: - parallel_install(manager_cls, next_specs, seen_names) - - -def build_cli_args(libs: list, platforms: list, tools: list) -> list: - return [ - arg - for flag, specs in (("-l", libs), ("-p", platforms), ("-t", tools)) - for spec in specs - for arg in (flag, spec) - ] - - -def main() -> None: - parser = argparse.ArgumentParser(description="") - parser.add_argument("file", help="Path to platformio.ini", nargs=1) - parser.add_argument( - "-l", "--libraries", help="Install libraries", action="store_true" - ) - parser.add_argument( - "-p", "--platforms", help="Install platforms", action="store_true" - ) - parser.add_argument("-t", "--tools", help="Install tools", action="store_true") - args = parser.parse_args() - start_cwd = Path.cwd() - libs, platforms, tools = parse_specs(args.file[0], args) - - # Platforms stay serial: PlatformPackageManager.install runs an - # on_installed hook the private _install path would skip - if PARALLEL_AVAILABLE: - wave_groups = [(ToolPackageManager, tools), (LibraryPackageManager, libs)] - else: # pragma: no cover - wave_groups = [] - print( - f"PlatformIO layout changed ({IMPORT_ERROR}); serial install only", - flush=True, - ) - for manager_cls, specs in wave_groups: - try: - parallel_install(manager_cls, specs) - except (CleanupError, LockReleaseError, KeyboardInterrupt): - # A torn package or a held lock must fail the build - raise - except BaseException: # noqa: BLE001 - # BaseException: a worker postinstall's SystemExit must not - # skip the authoritative serial pass (partial deps, exit 0) - print("Parallel preinstall failed, falling back to serial", flush=True) - traceback.print_exc() - - # Postinstall scripts chdir process-wide (pio's fs.cd captures its - # restore path at construction); pin the authoritative pass's cwd - subprocess.check_call( - ["platformio", "pkg", "install", "-g", *build_cli_args(libs, platforms, tools)], - close_fds=False, - cwd=start_cwd, - ) - - -if __name__ == "__main__": - main() diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py deleted file mode 100644 index 0dce00785b..0000000000 --- a/tests/script/test_platformio_install_deps.py +++ /dev/null @@ -1,649 +0,0 @@ -"""Tests for script/platformio_install_deps.py.""" - -from argparse import Namespace -import importlib.util -import inspect -from pathlib import Path -import shutil -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from platformio import fs -from platformio.cache import ContentCache -from platformio.exception import InvalidJSONFile -from platformio.package.manager._install import PackageManagerInstallMixin -from platformio.package.manager.base import BasePackageManager -from platformio.package.manager.library import LibraryPackageManager -from platformio.package.manager.tool import ToolPackageManager -from platformio.package.meta import PackageCompatibility, PackageItem, PackageSpec -import pytest -from semantic_version import Version - -_SCRIPT = Path(__file__).parents[2] / "script" / "platformio_install_deps.py" - - -def _load_script(): - spec = importlib.util.spec_from_file_location("platformio_install_deps", _SCRIPT) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # The real ContentCache would create dirs under the user's core dir - module.ContentCache = lambda *_: None - return module - - -def test_spec_key_collapses_destinations() -> None: - """Two specs delivering one package share a directory and one key.""" - mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" - assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" - assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( - "esp32async/asynctcp @ 3.5.0" - ) - url = "https://github.com/pioarduino/platform-espressif32/releases/download/{v}/platform-espressif32.zip" - assert mod.spec_key(url.format(v="55.03.311")) == mod.spec_key( - url.format(v="54.03.20") - ) - - -def test_parse_specs_and_cli_args(tmp_path: Path) -> None: - """Parsing skips unpinned and interpolated entries; the CLI rebuild - keeps the original flag pairing.""" - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:a]\n" - "platform = fake/platform@1\n" - "lib_deps =\n" - " esphome/noise-c @ 1.0\n" - " ${common.lib_deps}\n" - " internal_lib\n" - "[env:b]\n" - "lib_deps =\n" - " esphome/noise-c @ 1.0\n" - ) - mod = _load_script() - args = Namespace(libraries=True, platforms=True, tools=False) - libs, platforms, tools = mod.parse_specs(str(ini), args) - # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 1.0"] - assert platforms == ["fake/platform@1"] - assert tools == [] - assert mod.build_cli_args(libs, platforms, tools) == [ - "-l", - "esphome/noise-c @ 1.0", - "-p", - "fake/platform@1", - ] - - -class _FakeManager: - """Scripted manager_cls: records installs, raises on demand.""" - - installed: set = set() - fail: set = set() - calls: list = [] - lock_events: list = [] - base_dir: str = "" # per-test tmp base; set by _reset_fake - - def __init__(self, package_dir) -> None: - assert package_dir is None - - @staticmethod - def _key(spec) -> str: - return spec if isinstance(spec, str) else str(spec) - - def get_package(self, spec): - if self._key(spec) in self.installed: - return SimpleNamespace(path="/tmp/fake-pkg", spec=self._key(spec)) - return None - - def memcache_reset(self) -> None: - type(self).resets = getattr(type(self), "resets", 0) + 1 - - @property - def package_dir(self) -> str: - return str(Path(type(self).base_dir) / "packages") - - def get_download_dir(self) -> str: - return str(Path(type(self).base_dir) / "downloads") - - def get_tmp_dir(self) -> str: - return str(Path(type(self).base_dir) / "tmp") - - def lock(self) -> None: - type(self).lock_events.append("lock") - - def unlock(self) -> None: - type(self).lock_events.append("unlock") - - def _install(self, spec, skip_dependencies, compatibility=None): - assert skip_dependencies is True - if self._key(spec) in self.fail: - raise RuntimeError("boom") - type(self).calls.append(spec) - type(self).compat_calls.append((self._key(spec), compatibility)) - type(self).installed.add(self._key(spec)) # atomic under the GIL - - def get_pkg_dependencies(self, pkg): - return getattr(type(self), "deps", {}).get(pkg.spec) - - dependency_to_spec = staticmethod(BasePackageManager.dependency_to_spec) - - -def _reset_fake(base_dir: str = "", **kwargs) -> type: - # A fresh subclass per test: nothing leaks between tests through the - # class-level scripted state - return type( - "_ScriptedManager", - (_FakeManager,), - { - "base_dir": base_dir, - "installed": kwargs.get("installed", set()), - "fail": kwargs.get("fail", set()), - "calls": [], - "compat_calls": [], - "lock_events": [], - }, - ) - - -def test_parallel_install_empty_specs_is_a_no_op(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - mod.parallel_install(cls, []) - assert cls.calls == [] and cls.lock_events == [] - - -def test_parallel_install_behavior(tmp_path: Path) -> None: - """Duplicates collapse to one install, installed specs are filtered, - URL specs stay out of the wave, and the lock wraps the pool.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/already @ 1.0"}) - mod.parallel_install( - cls, - [ - "esphome/noise-c @ 1.0", - "esphome/noise-c @ 1.0", - "esphome/already @ 1.0", - "https://x/framework.tar.xz", - ], - ) - assert cls.calls == ["esphome/noise-c @ 1.0"] - assert cls.lock_events == ["lock", "unlock"] - - -def test_parallel_install_failure_cleans_torn_destination( - tmp_path: Path, capsys -) -> None: - """A failed install resets the memcache, removes what get_package can - see, and reports; the others still install.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - removed = [] - - torn = str(tmp_path / "packages" / "torn-pkg") # never created; only rmtree'd - - def get_package(self, spec): - if spec == "esphome/bad @ 1.0" and getattr(cls, "resets", 0): - return SimpleNamespace(path=torn, spec=spec) - return _FakeManager.get_package(self, spec) - - cls.get_package = get_package # throwaway subclass; nothing to restore - with patch.object(mod.fs, "rmtree", side_effect=removed.append): - mod.parallel_install(cls, ["esphome/bad @ 1.0", "esphome/good @ 1.0"]) - assert "esphome/good @ 1.0" in cls.calls - assert removed == [torn] - out = capsys.readouterr().out - assert "Pre-install of esphome/bad @ 1.0 failed" in out - assert "Pre-install failed for 1 of 2 package(s)" in out - - -def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: - """Dependencies of wave-installed packages install in a second wave, - deduped by name; name-only platform libs stay with the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - {"name": "SPI"}, - ], - "esphome/wg @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) - assert len(cls.calls) == 3 # the shared dep installs exactly once - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} - # Wave-1 strings carry no compatibility; the dependency wave does - compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 1.0"] is None - dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) - assert dep_compat is not None # mirrors pio's install_dependency - - -def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: - """A dependency pinned to a URL surfaces as spec.uri; it must stay out - of the wave like string URL specs do.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"name": "vendored", "version": "https://github.com/x/y.git"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} - - -def test_failed_cleanup_fails_the_build(tmp_path: Path) -> None: - """A torn destination still on disk after rmtree must fail the build: - fs.rmtree never raises (its onexc handler prints), so only the - destination's absence proves the cleanup worked.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls.get_package = get_package # throwaway subclass; nothing to restore - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] # still released - - -def test_unverifiable_torn_destination_fails_the_build(tmp_path: Path) -> None: - """When the scan fails, the spec's own .piopm decides: an unremovable - leftover fails the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - def bad_reset(self): - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_unverifiable_scan_without_leftover_degrades(tmp_path: Path, capsys) -> None: - """A failing scan with no destination on disk is never a build - failure blaming this spec.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - resets = {"n": 0} - - def bad_reset(self): - # Fail clean_torn's reset; the coordinator's later reset works - resets["n"] += 1 - if resets["n"] <= 1: - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unresolvable_torn_destination_is_printed(tmp_path: Path, capsys) -> None: - """A failed install with no resolvable package prints, so an invisible - torn directory is at least traceable.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unparsable_torn_destination_is_removed(tmp_path: Path, capsys) -> None: - """A torn dir get_package cannot resolve but whose .piopm names the - spec is removed instead of surviving into the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not dest.exists() - assert "Removed torn destination" in capsys.readouterr().out - - -def test_parse_specs_tools_branch(tmp_path: Path) -> None: - """platform_packages parsing keeps owner'd tools and rewrites github - URL pins to bare URLs the wave then skips via parsed.uri.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:t]\n" - "platform_packages =\n" - " ${common.platform_packages}\n" - " platformio/tool-scons@~4.40801.0\n" - " framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip\n" - ) - args = Namespace(libraries=False, platforms=False, tools=True) - libs, platforms, tools = mod.parse_specs(str(ini), args) - assert libs == [] and platforms == [] - assert tools == [ - "platformio/tool-scons@~4.40801.0", - "https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip", - ] - assert mod.build_cli_args([], [], tools)[:2] == ["-t", tools[0]] - - -def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: - """Already-installed top-level packages still feed the dependency - wave; a warm store can be missing a transitive dep.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) - assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] - - -def test_worker_system_exit_still_cleans(tmp_path: Path, capsys) -> None: - """A worker SystemExit runs the torn cleanup before propagating; the - serial pass must never trust its leftovers.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def exiting_install(self, spec, skip_dependencies, compatibility=None): - raise SystemExit(0) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls._install = exiting_install - cls.get_package = get_package - - def real_rmtree(path): - Path(path).rmdir() - - with ( - patch.object(mod.fs, "rmtree", real_rmtree), - pytest.raises(SystemExit), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unlock_failure_is_fatal(tmp_path: Path) -> None: - """A failed unlock must fail the build: the serial pass in another - process would block on the held flock.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - - def bad_unlock(self): - raise OSError("flock broke") - - cls.unlock = bad_unlock - with pytest.raises(mod.LockReleaseError, match="manager lock"): - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - - -def test_unlock_failure_keeps_inflight_error_as_context(tmp_path: Path) -> None: - """An in-flight CleanupError stays attached when the unlock fault - takes over the raise.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - def bad_unlock(self): - raise OSError("flock broke") - - cls.get_package = get_package - cls.unlock = bad_unlock - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # leaves torn - pytest.raises(mod.LockReleaseError) as err, - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert isinstance(err.value.__cause__.__context__, mod.CleanupError) - - -def test_chdir_failure_does_not_fail_the_wave(tmp_path: Path, monkeypatch) -> None: - """A lost cwd is suppressed: further waves may misbehave and fall to - the serial pass, whose cwd is pinned.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - monkeypatch.setattr(mod.os, "chdir", MagicMock(side_effect=OSError("gone"))) - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - assert cls.calls == ["esphome/good @ 1.0"] - - -def test_piopm_match_removes_manifest_named_torn_dir(tmp_path: Path, capsys) -> None: - """A torn dir named by its manifest (not the registry spec) is found - through its .piopm and removed.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "ManifestName" - torn.mkdir(parents=True) - (torn / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - innocent = tmp_path / "packages" / "innocent" - innocent.mkdir() - (innocent / ".piopm").write_text('{"spec": {"owner": "o", "name": "other"}}') - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - assert innocent.exists() # another package's valid metadata survives - assert "Removed torn destination" in capsys.readouterr().out - - -def test_unscannable_package_dir_fails_the_build(tmp_path: Path) -> None: - """A storage dir the cleanup cannot scan is not proof of cleanliness.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - real_iterdir = Path.iterdir - - def broken_iterdir(self): - if self.name == "packages": - raise PermissionError("denied") - return real_iterdir(self) - - with ( - patch.object(Path, "iterdir", broken_iterdir), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_stray_file_in_package_dir_is_ignored(tmp_path: Path) -> None: - """A plain file (or a pio-link) beside the packages is skipped by - pio's own scan and must never hard-fail the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - (tmp_path / "packages").mkdir(parents=True) - (tmp_path / "packages" / "stray.pio-link").write_text("x") - (tmp_path / "packages" / "no-metadata").mkdir() # pio overwrites these - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert (tmp_path / "packages" / "stray.pio-link").exists() - assert (tmp_path / "packages" / "no-metadata").exists() - - -def test_unreadable_piopm_dir_is_removed(tmp_path: Path) -> None: - """A persistently corrupt .piopm under this spec's own name would - crash pio's storage scan; the dir is removed rather than left to - break the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - (torn / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unreadable_piopm_under_other_name_survives(tmp_path: Path) -> None: - """A corrupt .piopm in another package's dir may be a worker mid-copy; - a failing spec must not remove a directory it does not own.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - other = tmp_path / "packages" / "innocent" - other.mkdir(parents=True) - (other / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert other.exists() - - -def test_unexpected_cleanup_class_becomes_cleanup_error(tmp_path: Path) -> None: - """Cleanup failures of any class fail the build; nothing may be - downgraded to the serial fallback over a torn directory.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - with ( - patch.object( - mod, "piopm_matches", MagicMock(side_effect=ValueError("bad spec")) - ), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_main_cleanup_error_fails_before_generic_fallback(tmp_path: Path) -> None: - """A CleanupError must escape main's serial fallback: the clause order - decides whether a stuck torn package fails the image build.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object( - mod, "parallel_install", side_effect=mod.CleanupError("stuck torn pkg") - ), - patch.object(mod.subprocess, "check_call"), - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - pytest.raises(mod.CleanupError), - ): - mod.main() - - -def test_main_generic_failure_still_runs_serial_pass(tmp_path: Path) -> None: - """A non-CleanupError wave failure prints, dumps the traceback, and - still reaches the authoritative serial pass with the pinned cwd.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object(mod, "parallel_install", side_effect=RuntimeError("boom")), - patch.object(mod.subprocess, "check_call") as mock_call, - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - ): - mod.main() - mock_call.assert_called_once() - args, kwargs = mock_call.call_args - assert args[0][:4] == ["platformio", "pkg", "install", "-g"] - assert "esphome/x @ 1.0" in args[0] - assert kwargs["cwd"] == Path.cwd() - - -def test_content_cache_creates_its_dir(tmp_path: Path, monkeypatch) -> None: - """The cold-cache hardening relies on ContentCache.__init__ creating - the namespace dir; pin the side effect, not mere callability.""" - monkeypatch.setenv("PLATFORMIO_CACHE_DIR", str(tmp_path / "cache")) - ContentCache("http") - assert (tmp_path / "cache" / "http").is_dir() - - -def test_piopm_matches_without_name_matches_nothing(tmp_path: Path) -> None: - """A spec with no derivable name can never match a directory.""" - mod = _load_script() - assert mod.piopm_matches(str(tmp_path), "") == [] - - -def test_unresolvable_spec_stays_out_of_the_wave(tmp_path: Path, capsys) -> None: - """A spec with no derivable name is left to the serial pass; a raw - string key would break the one-per-destination dedupe.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - nameless = PackageSpec(requirements="^1.0") - mod.parallel_install(cls, [nameless]) - assert cls.calls == [] - assert "Skipping unresolvable spec" in capsys.readouterr().out - - -def test_parallel_install_unlocks_when_pool_fails(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - with ( - patch.object(mod, "ThreadPoolExecutor", side_effect=RuntimeError("no")), - pytest.raises(RuntimeError), - ): - mod.parallel_install(cls, ["esphome/a @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] - - -def test_parse_specs_unreadable_ini_fails_loudly(tmp_path: Path) -> None: - """A bad path must not silently build an image with no dependencies.""" - mod = _load_script() - args = Namespace(libraries=True, platforms=False, tools=False) - with pytest.raises(SystemExit): - mod.parse_specs(str(tmp_path / "missing.ini"), args) - - -def test_platformio_surface_for_install_deps_script() -> None: - """A PlatformIO bump that changes these members must fail here, not - silently turn the docker image's parallel preinstall into a no-op.""" - # The script calls these positionally; pin the positions, not just - # membership, so a parameter reorder trips the wire too - params = inspect.signature(PackageManagerInstallMixin._install).parameters - assert list(params)[1] == "spec" - assert "skip_dependencies" in params - assert "compatibility" in params - for cls in (ToolPackageManager, LibraryPackageManager): - assert list(inspect.signature(cls.__init__).parameters)[1] == "package_dir" - for name in ( - "lock", - "unlock", - "get_package", - "memcache_reset", - "get_pkg_dependencies", - "dependency_to_spec", - "get_download_dir", - "get_tmp_dir", - ): - assert callable(getattr(BasePackageManager, name)) - # Losing any of these turns the wave into main()'s silent serial - # fallback: ensure_spec runs in the coordinator, the spec attributes - # feed the dedupe, cleanup, and dependency filters - assert callable(BasePackageManager.ensure_spec) - spec = PackageSpec("owner/name @ ^1.0") - assert spec.name == "name" - assert spec.owner == "owner" - assert spec.uri is None - assert spec.external is False - assert Version("1.5.0") in spec.requirements - # The failure-cleanup path degrades to a single line if these vanish - assert callable(fs.rmtree) - assert callable(fs.load_json) - # piopm_matches only tolerates a corrupt .piopm through this base; - # losing it would flip a wave failure from degrade to build failure - assert issubclass(InvalidJSONFile, ValueError) - assert PackageItem("pkg-dir").path == "pkg-dir" - assert callable(PackageCompatibility.from_dependency) From 01fc7511aecf6c34ddf9694353cb9f822292be23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:15:21 +0100 Subject: [PATCH 226/316] [core] Call a lambda whose only statement returns a braced value (#19503) --- esphome/cpp_generator.py | 8 +++++++- tests/unit_tests/test_cpp_generator.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index f394337604..a681583053 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1212,7 +1212,13 @@ def call_lambda(lamb: LambdaExpression) -> Expression: assert lamb.return_type is not None, "Lambda must have a return type to be called" expr = lamb.content.strip() # A lone `return ;` reduces to the expression; anything longer is called as is. - if re.match(r"^return\b", expr) and expr.endswith(";") and expr.count(";") == 1: + # A braced return such as `return {};` needs the lambda's return type, so it is called. + if ( + re.match(r"^return\b", expr) + and expr.endswith(";") + and expr.count(";") == 1 + and not expr[6:].lstrip().startswith("{") + ): expr = RawExpression(expr[6:-1].strip()) # Don't cast if the return type is a class if isinstance(lamb.return_type, MockObjClass): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index a618ad50e0..fa6255b8c9 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -260,6 +260,15 @@ class TestCallLambda: assert isinstance(result, cg.CallExpression) assert str(result).endswith("}()") + def test_call_lambda__braced_return_is_called(self) -> None: + """A braced return needs the lambda's return type, so it is not reduced.""" + lamb = cg.LambdaExpression(("return {};",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert "static_cast" not in str(result) + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): """A class return type is not cast, since static_cast doesn't apply to arbitrary class types.""" From 56f7280d551ddf6636485a2794ebd67ca96d0067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:16:47 +0100 Subject: [PATCH 227/316] [switch] Use register_apply_action for the switch actions (#19498) --- esphome/components/switch/__init__.py | 40 ++++--------- esphome/components/switch/automation.h | 58 ------------------- .../components/template/switch/__init__.py | 11 +--- tests/components/template/common-base.yaml | 6 ++ 4 files changed, 20 insertions(+), 95 deletions(-) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 18b95113cc..e9d49b0ed0 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -54,12 +54,6 @@ RESTORE_MODES = { } -ControlAction = switch_ns.class_("ControlAction", automation.Action) -ToggleAction = switch_ns.class_("ToggleAction", automation.Action) -TurnOffAction = switch_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) -SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) - SwitchCondition = switch_ns.class_("SwitchCondition", Condition) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -193,29 +187,19 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "switch.control", + SWITCH_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "control", cg.bool_), ) -async def switch_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -async def switch_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("switch.toggle", "toggle()"), + ("switch.turn_off", "turn_off()"), + ("switch.turn_on", "turn_on()"), +): + automation.register_apply_action( + _name, SWITCH_ACTION_SCHEMA, automation.ApplyCall(_call) + ) @automation.register_condition("switch.is_on", SwitchCondition, SWITCH_ACTION_SCHEMA) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 158fb08baf..ce81f53475 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,53 +6,6 @@ namespace esphome::switch_ { -template class TurnOnAction final : public Action { - public: - explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_on(); } - - protected: - Switch *switch_; -}; - -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_off(); } - - protected: - Switch *switch_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->toggle(); } - - protected: - Switch *switch_; -}; - -template class ControlAction final : public Action { - public: - explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} - - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.optional_value(x...); - if (state.has_value()) { - this->switch_->control(*state); - } - } - - protected: - Switch *switch_; -}; - template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} @@ -92,15 +45,4 @@ class SwitchTurnOffTrigger final : public Trigger<> { } }; -template class SwitchPublishAction final : public Action { - public: - SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { this->switch_->publish_state(this->state_.value(x...)); } - - protected: - Switch *switch_; -}; - } // namespace esphome::switch_ diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 0b24686936..f8e509faf3 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -79,20 +79,13 @@ async def to_code(config): cg.add(var.set_assumed_state(True)) -@automation.register_action( +automation.register_apply_action( "switch.template.publish", - switch.SwitchPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(switch.Switch), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def switch_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 53e1f7af6f..a87e2e980a 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -257,8 +257,14 @@ switch: return false; turn_on_action: - logger.log: "turn_on_action" + - switch.template.publish: + id: test_switch + state: true turn_off_action: - logger.log: "turn_off_action" + - switch.template.publish: + id: test_switch + state: !lambda return false; button: - platform: template From e7ff903284a09af57f0fccafac48735a4a77f7ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:17:06 +0100 Subject: [PATCH 228/316] [valve] Use register_apply_action for valve.control (#19474) --- esphome/components/valve/__init__.py | 61 +++++---------------------- esphome/components/valve/automation.h | 28 ------------ 2 files changed, 11 insertions(+), 78 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7d98af402d..692759f3e4 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -21,14 +21,14 @@ from esphome.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_WATER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -43,7 +43,6 @@ DEVICE_CLASSES = [ valve_ns = cg.esphome_ns.namespace("valve") Valve = valve_ns.class_("Valve", cg.EntityBase) -ValveCall = valve_ns.class_("ValveCall") VALVE_OPEN = valve_ns.VALVE_OPEN VALVE_CLOSED = valve_ns.VALVE_CLOSED @@ -67,7 +66,6 @@ OpenAction = valve_ns.class_("OpenAction", automation.Action) CloseAction = valve_ns.class_("CloseAction", automation.Action) StopAction = valve_ns.class_("StopAction", automation.Action) ToggleAction = valve_ns.class_("ToggleAction", automation.Action) -ControlAction = valve_ns.class_("ControlAction", automation.Action) ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) ValveIsOpenCondition = valve_ns.class_("ValveIsOpenCondition", Condition) ValveIsClosedCondition = valve_ns.class_("ValveIsClosedCondition", Condition) @@ -226,53 +224,16 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA, synchronous=True +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both dispatch to set_position. +automation.register_apply_action( + "valve.control", + VALVE_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + call="make_call", ) -async def valve_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most - # one is present and both dispatch to set_position. - FIELDS = ( - (CONF_STOP, "set_stop", cg.bool_), - (CONF_STATE, "set_position", cg.float_), - (CONF_POSITION, "set_position", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ValveCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 63d03a889b..9fdb7665b3 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -46,34 +46,6 @@ template class ToggleAction final : public Action { Valve *valve_; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); - ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->valve_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Valve *valve_; - ApplyFn apply_; -}; - template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} From 418ae738702d44be75e9d070c748d673e2b0bc24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:18:55 +0100 Subject: [PATCH 229/316] [template] Use register_apply_action for climate.template.publish (#19495) --- .../components/template/climate/__init__.py | 76 ++++++------------- .../components/template/climate/automation.h | 57 -------------- .../template/climate/template_climate.h | 3 +- tests/components/template/common-base.yaml | 4 + 4 files changed, 30 insertions(+), 110 deletions(-) delete mode 100644 esphome/components/template/climate/automation.h diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index c39ea8f80e..11ffaf282c 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -28,8 +28,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_HIGH, CONF_TARGET_TEMPERATURE_LOW, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import template_ns @@ -53,11 +51,6 @@ CONF_SET_PRESET_ACTION = "set_preset_action" CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action" TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component) -TemplateClimatePublishAction = template_ns.class_( - "TemplateClimatePublishAction", - automation.Action, - cg.Parented.template(TemplateClimate), -) TemplateClimateRestoreMode = template_ns.enum( "TemplateClimateRestoreMode", is_class=True @@ -416,50 +409,31 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "climate.template.publish", - TemplateClimatePublishAction, CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_ + ), + automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode), + automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", climate.ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, "set_custom_fan_mode(StringRef({}))", cg.std_string + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", climate.ClimateSwingMode), + automation.ApplyField(CONF_PRESET, "set_preset", climate.ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, "set_custom_preset(StringRef({}))", cg.std_string + ), + automation.ApplyCall("publish_state()"), ) -async def climate_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None: - cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None: - cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None: - cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - cg.add( - var.set_target_temperature_high(await cg.templatable(v, args, cg.float_)) - ) - if (v := config.get(CONF_TARGET_HUMIDITY)) is not None: - cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_MODE)) is not None: - cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode))) - if (v := config.get(CONF_ACTION)) is not None: - cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction))) - if (v := config.get(CONF_FAN_MODE)) is not None: - cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode))) - if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string))) - if (v := config.get(CONF_SWING_MODE)) is not None: - cg.add( - var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode)) - ) - if (v := config.get(CONF_PRESET)) is not None: - cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset))) - if (v := config.get(CONF_CUSTOM_PRESET)) is not None: - cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string))) - - return var diff --git a/esphome/components/template/climate/automation.h b/esphome/components/template/climate/automation.h deleted file mode 100644 index 49a79ace2f..0000000000 --- a/esphome/components/template/climate/automation.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "template_climate.h" -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template -class TemplateClimatePublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, current_temperature) - TEMPLATABLE_VALUE(float, current_humidity) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(climate::ClimateMode, mode) - TEMPLATABLE_VALUE(climate::ClimateAction, action) - TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode) - TEMPLATABLE_VALUE(climate::ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - - void play(const Ts &...x) override { - if (this->current_temperature_.has_value()) - this->parent_->current_temperature = this->current_temperature_.value(x...); - if (this->current_humidity_.has_value()) - this->parent_->current_humidity = this->current_humidity_.value(x...); - if (this->target_temperature_.has_value()) - this->parent_->set_target_temperature(this->target_temperature_.value(x...)); - if (this->target_temperature_low_.has_value()) - this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...)); - if (this->target_temperature_high_.has_value()) - this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...)); - if (this->target_humidity_.has_value()) - this->parent_->set_target_humidity(this->target_humidity_.value(x...)); - if (this->mode_.has_value()) - this->parent_->set_mode(this->mode_.value(x...)); - if (this->action_.has_value()) - this->parent_->action = this->action_.value(x...); - if (this->fan_mode_.has_value()) - this->parent_->set_fan_mode(this->fan_mode_.value(x...)); - if (this->custom_fan_mode_.has_value()) - this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...))); - if (this->swing_mode_.has_value()) - this->parent_->set_swing_mode(this->swing_mode_.value(x...)); - if (this->preset_.has_value()) - this->parent_->set_preset(this->preset_.value(x...)); - if (this->custom_preset_.has_value()) - this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...))); - - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h index 5448488c34..4cdf213315 100644 --- a/esphome/components/template/climate/template_climate.h +++ b/esphome/components/template/climate/template_climate.h @@ -50,8 +50,7 @@ class TemplateClimate final : public climate::Climate, public Component { Trigger *get_set_preset_trigger() { return &this->set_preset_trigger_; } Trigger *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; } - // Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the - // protected setters, and by codegen to apply `initial_state:` before setup() runs. + // Public so the climate.template.publish action and the initial_state codegen can set them. void set_target_temperature(float value) { this->target_temperature = value; } void set_target_temperature_low(float value) { this->target_temperature_low = value; } void set_target_temperature_high(float value) { this->target_temperature_high = value; } diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index a87e2e980a..8c07b3a2b7 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -45,6 +45,10 @@ esphome: id: template_climate_custom_modes custom_fan_mode: "turbo" custom_preset: "eco_plus" + - climate.template.publish: + id: template_climate_custom_modes + custom_fan_mode: !lambda return std::string("turbo"); + custom_preset: !lambda return std::string("eco_plus"); # Test C++ API: set_template() with stateless lambda (no captures) # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. From 14baf03aadf6044545d4a8fd3bbc5e5ec2ed1335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:07 +0100 Subject: [PATCH 230/316] [cover] Use register_apply_action for the open, close, stop and toggle actions (#19500) --- esphome/components/cover/__init__.py | 63 ++++------------------ esphome/components/cover/automation.h | 40 -------------- tests/components/template/common-base.yaml | 4 ++ 3 files changed, 13 insertions(+), 94 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 05a9afad61..f7e029ad9e 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -87,10 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -OpenAction = cover_ns.class_("OpenAction", automation.Action) -CloseAction = cover_ns.class_("CloseAction", automation.Action) -StopAction = cover_ns.class_("StopAction", automation.Action) -ToggleAction = cover_ns.class_("ToggleAction", automation.Action) CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( @@ -251,56 +247,15 @@ COVER_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_open_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_close_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_toggle_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _command in ( + ("cover.open", "set_command_open()"), + ("cover.close", "set_command_close()"), + ("cover.stop", "set_command_stop()"), + ("cover.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, COVER_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) COVER_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 20f56e1753..9af4a1eee5 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,46 +6,6 @@ namespace esphome::cover { -template class OpenAction final : public Action { - public: - explicit OpenAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_open().perform(); } - - protected: - Cover *cover_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_close().perform(); } - - protected: - Cover *cover_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_stop().perform(); } - - protected: - Cover *cover_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_toggle().perform(); } - - protected: - Cover *cover_; -}; - template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 8c07b3a2b7..fd868b9709 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -377,6 +377,10 @@ cover: - cover.control: id: template_cover_actions stop: true + - cover.open: template_cover_actions + - cover.close: template_cover_actions + - cover.stop: template_cover_actions + - cover.toggle: template_cover_actions # CONF_STATE alias for position - cover.control: id: template_cover_actions From 547624edf9eddfb47d2bb5a7553f82856201457c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:24 +0100 Subject: [PATCH 231/316] [at581x] Use register_apply_action for the settings and reset actions (#19496) --- esphome/components/at581x/__init__.py | 105 +++++++------------------ esphome/components/at581x/at581x.h | 4 + esphome/components/at581x/automation.h | 69 ---------------- 3 files changed, 32 insertions(+), 146 deletions(-) delete mode 100644 esphome/components/at581x/automation.h diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 193e62f615..a29e166745 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] @@ -80,30 +78,21 @@ async def to_code(config: ConfigType) -> None: # Actions -AT581XResetAction = at581x_ns.class_("AT581XResetAction", automation.Action) -AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Action) -@automation.register_action( +automation.register_apply_action( "at581x.reset", - AT581XResetAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(AT581XComponent), - } - ), - synchronous=True, + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(AT581XComponent)}), + automation.ApplyCall("reset_hardware_frontend()"), ) -async def at581x_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var + +def _megahertz(value: float) -> int: + return int(value / 1000000) + + +def _microamps(value: float) -> int: + return int(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( @@ -111,7 +100,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( cv.Required(CONF_ID): cv.use_id(AT581XComponent), cv.Optional(CONF_HW_FRONTEND_RESET): cv.templatable(cv.boolean), cv.Optional(CONF_FREQUENCY, default="5800MHz"): cv.templatable( - cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ)) + cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ), _megahertz) ), cv.Optional(CONF_SENSING_DISTANCE, default=823): cv.templatable( cv.int_range(min=0, max=1023) @@ -123,7 +112,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) ), cv.Optional(CONF_POWER_CONSUMPTION, default="70uA"): cv.templatable( - cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION)) + cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION), _microamps) ), cv.Optional(CONF_PROTECT_TIME, default="1000ms"): cv.templatable( cv.All( @@ -165,60 +154,22 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) -@automation.register_action( +# i2c_write_config() must follow the setters: it flushes the staged values. +automation.register_apply_action( "at581x.settings", - AT581XSettingsAction, RADAR_SETTINGS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "set_frequency", cg.int_), + automation.ApplyField(CONF_SENSING_DISTANCE, "set_sensing_distance", cg.int_), + automation.ApplyField( + CONF_POWERON_SELFCHECK_TIME, "set_poweron_selfcheck_time", cg.int_ + ), + automation.ApplyField(CONF_POWER_CONSUMPTION, "set_power_consumption", cg.int_), + automation.ApplyField(CONF_PROTECT_TIME, "set_protect_time", cg.int_), + automation.ApplyField(CONF_TRIGGER_BASE, "set_trigger_base", cg.int_), + automation.ApplyField(CONF_TRIGGER_KEEP, "set_trigger_keep", cg.int_), + automation.ApplyField(CONF_STAGE_GAIN, "set_stage_gain", cg.int_), + automation.ApplyCall("i2c_write_config()"), + automation.ApplyField( + CONF_HW_FRONTEND_RESET, "reset_hardware_frontend_if", cg.bool_ + ), ) -async def at581x_settings_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - # Radar configuration - if frontend_reset := config.get(CONF_HW_FRONTEND_RESET): - template_ = await cg.templatable(frontend_reset, args, cg.int8) - cg.add(var.set_hw_frontend_reset(template_)) - - if freq := config.get(CONF_FREQUENCY): - if not cg.is_template(freq): - freq = int(freq / 1000000) - template_ = await cg.templatable(freq, args, cg.int_) - cg.add(var.set_frequency(template_)) - - if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: - template_ = await cg.templatable(sens_dist, args, cg.int_) - cg.add(var.set_sensing_distance(template_)) - - if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int_) - cg.add(var.set_poweron_selfcheck_time(template_)) - - if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int_) - cg.add(var.set_protect_time(template_)) - - if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int_) - cg.add(var.set_trigger_base(template_)) - - if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int_) - cg.add(var.set_trigger_keep(template_)) - - if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: - template_ = await cg.templatable(stage_gain, args, cg.int_) - cg.add(var.set_stage_gain(template_)) - - if power := config.get(CONF_POWER_CONSUMPTION): - if not cg.is_template(power): - power = int(power * 1000000) - template_ = await cg.templatable(power, args, cg.int_) - cg.add(var.set_power_consumption(template_)) - - return var diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index 594395e96d..df50d5142f 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -38,6 +38,10 @@ class AT581XComponent final : public Component, public i2c::I2CDevice { bool i2c_write_config(); bool reset_hardware_frontend(); + void reset_hardware_frontend_if(bool reset) { + if (reset) + this->reset_hardware_frontend(); + } bool i2c_write_reg(uint8_t addr, uint8_t data); bool i2c_write_reg(uint8_t addr, uint32_t data); bool i2c_write_reg(uint8_t addr, uint16_t data); diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h deleted file mode 100644 index a732d2bcc7..0000000000 --- a/esphome/components/at581x/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "at581x.h" - -namespace esphome::at581x { - -template class AT581XResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } -}; - -template class AT581XSettingsAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) - TEMPLATABLE_VALUE(int, frequency) - TEMPLATABLE_VALUE(int, sensing_distance) - TEMPLATABLE_VALUE(int, poweron_selfcheck_time) - TEMPLATABLE_VALUE(int, power_consumption) - TEMPLATABLE_VALUE(int, protect_time) - TEMPLATABLE_VALUE(int, trigger_base) - TEMPLATABLE_VALUE(int, trigger_keep) - TEMPLATABLE_VALUE(int, stage_gain) - - void play(const Ts &...x) { - if (this->frequency_.has_value()) { - int v = this->frequency_.value(x...); - this->parent_->set_frequency(v); - } - if (this->sensing_distance_.has_value()) { - int v = this->sensing_distance_.value(x...); - this->parent_->set_sensing_distance(v); - } - if (this->poweron_selfcheck_time_.has_value()) { - int v = this->poweron_selfcheck_time_.value(x...); - this->parent_->set_poweron_selfcheck_time(v); - } - if (this->power_consumption_.has_value()) { - int v = this->power_consumption_.value(x...); - this->parent_->set_power_consumption(v); - } - if (this->protect_time_.has_value()) { - int v = this->protect_time_.value(x...); - this->parent_->set_protect_time(v); - } - if (this->trigger_base_.has_value()) { - int v = this->trigger_base_.value(x...); - this->parent_->set_trigger_base(v); - } - if (this->trigger_keep_.has_value()) { - int v = this->trigger_keep_.value(x...); - this->parent_->set_trigger_keep(v); - } - if (this->stage_gain_.has_value()) { - int v = this->stage_gain_.value(x...); - this->parent_->set_stage_gain(v); - } - - // This actually perform all the modification on the system - this->parent_->i2c_write_config(); - - if (this->hw_frontend_reset_.has_value() && this->hw_frontend_reset_.value(x...) == true) { - this->parent_->reset_hardware_frontend(); - } - } -}; -} // namespace esphome::at581x From 3b570d84aede6c2a9238610a0f0f8b7f9b4289dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:46 +0100 Subject: [PATCH 232/316] [rf_bridge] Use register_apply_action for the actions (#19497) --- esphome/components/rf_bridge/__init__.py | 130 ++++++----------------- esphome/components/rf_bridge/rf_bridge.h | 103 ------------------ tests/components/rf_bridge/common.yaml | 3 + 3 files changed, 36 insertions(+), 200 deletions(-) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9863379b79..fed8365e9e 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -25,29 +25,6 @@ RFBridgeComponent = rf_bridge_ns.class_( RFBridgeData = rf_bridge_ns.struct("RFBridgeData") RFBridgeAdvancedData = rf_bridge_ns.struct("RFBridgeAdvancedData") -RFBridgeSendCodeAction = rf_bridge_ns.class_( - "RFBridgeSendCodeAction", automation.Action -) -RFBridgeSendAdvancedCodeAction = rf_bridge_ns.class_( - "RFBridgeSendAdvancedCodeAction", automation.Action -) - -RFBridgeLearnAction = rf_bridge_ns.class_("RFBridgeLearnAction", automation.Action) - -RFBridgeStartAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartAdvancedSniffingAction", automation.Action -) -RFBridgeStopAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStopAdvancedSniffingAction", automation.Action -) - -RFBridgeStartBucketSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartBucketSniffingAction", automation.Action -) - -RFBridgeBeepAction = rf_bridge_ns.class_("RFBridgeBeepAction", automation.Action) - -RFBridgeSendRawAction = rf_bridge_ns.class_("RFBridgeSendRawAction", automation.Action) CONF_ON_CODE_RECEIVED = "on_code_received" CONF_ON_ADVANCED_CODE_RECEIVED = "on_advanced_code_received" @@ -110,74 +87,48 @@ RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_code", - RFBridgeSendCodeAction, RFBRIDGE_SEND_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_code(rf_bridge::RFBridgeData{{.sync = {}, .low = {}, .high = {}, .code = {}}})", + ( + (CONF_SYNC, cg.uint16), + (CONF_LOW, cg.uint16), + (CONF_HIGH, cg.uint16), + (CONF_CODE, cg.uint32), + ), + ), ) -async def rf_bridge_send_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_SYNC], args, cg.uint16) - cg.add(var.set_sync(template_)) - template_ = await cg.templatable(config[CONF_LOW], args, cg.uint16) - cg.add(var.set_low(template_)) - template_ = await cg.templatable(config[CONF_HIGH], args, cg.uint16) - cg.add(var.set_high(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) - return var RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) -@automation.register_action( - "rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.learn", RFBRIDGE_ID_SCHEMA, automation.ApplyCall("learn()") ) -async def rf_bridge_learnx_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_advanced_sniffing", - RFBridgeStartAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_advanced_sniffing()"), ) -async def rf_bridge_start_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.stop_advanced_sniffing", - RFBridgeStopAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_advanced_sniffing()"), ) -async def rf_bridge_stop_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_bucket_sniffing", - RFBridgeStartBucketSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_bucket_sniffing()"), ) -async def rf_bridge_start_bucket_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( @@ -190,22 +141,18 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_advanced_code", - RFBridgeSendAdvancedCodeAction, RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_advanced_code(rf_bridge::RFBridgeAdvancedData{{.length = {}, .protocol = {}, .code = {}}})", + ( + (CONF_LENGTH, cg.uint8), + (CONF_PROTOCOL, cg.uint8), + (CONF_CODE, cg.std_string), + ), + ), ) -async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_LENGTH], args, cg.uint8) - cg.add(var.set_length(template_)) - template_ = await cg.templatable(config[CONF_PROTOCOL], args, cg.uint8) - cg.add(var.set_protocol(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(template_)) - return var RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( @@ -216,18 +163,11 @@ RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_raw", - RFBridgeSendRawAction, RFBRIDGE_SEND_RAW_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_RAW, "send_raw", cg.std_string), ) -async def rf_bridge_send_raw_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_RAW], args, cg.std_string) - cg.add(var.set_raw(template_)) - return var RFBRIDGE_BEEP_SCHEMA = cv.Schema( @@ -238,12 +178,8 @@ RFBRIDGE_BEEP_SCHEMA = cv.Schema( ) -@automation.register_action( - "rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.beep", + RFBRIDGE_BEEP_SCHEMA, + automation.ApplyField(CONF_DURATION, "beep", cg.uint16), ) -async def rf_bridge_beep_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint16) - cg.add(var.set_duration(template_)) - return var diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index cbb1880ec5..200ef97810 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -5,7 +5,6 @@ #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" namespace esphome::rf_bridge { @@ -89,106 +88,4 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction final : public Action { - public: - RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, sync) - TEMPLATABLE_VALUE(uint16_t, low) - TEMPLATABLE_VALUE(uint16_t, high) - TEMPLATABLE_VALUE(uint32_t, code) - - void play(const Ts &...x) { - RFBridgeData data{}; - data.sync = this->sync_.value(x...); - data.low = this->low_.value(x...); - data.high = this->high_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendAdvancedCodeAction final : public Action { - public: - RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint8_t, length) - TEMPLATABLE_VALUE(uint8_t, protocol) - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) { - RFBridgeAdvancedData data{}; - data.length = this->length_.value(x...); - data.protocol = this->protocol_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_advanced_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeLearnAction final : public Action { - public: - RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->learn(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartAdvancedSniffingAction final : public Action { - public: - RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStopAdvancedSniffingAction final : public Action { - public: - RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->stop_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartBucketSniffingAction final : public Action { - public: - RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_bucket_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendRawAction final : public Action { - public: - RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, raw) - - void play(const Ts &...x) { this->parent_->send_raw(this->raw_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeBeepAction final : public Action { - public: - RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, duration) - - void play(const Ts &...x) { this->parent_->beep(this->duration_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - } // namespace esphome::rf_bridge diff --git a/tests/components/rf_bridge/common.yaml b/tests/components/rf_bridge/common.yaml index 427c3d783d..2ad0c4fa9d 100644 --- a/tests/components/rf_bridge/common.yaml +++ b/tests/components/rf_bridge/common.yaml @@ -27,3 +27,6 @@ rf_bridge: code: "ABC123" - rf_bridge.send_raw: raw: "AAA5070008001000ABC12355" + - rf_bridge.start_bucket_sniffing: + - rf_bridge.beep: + duration: !lambda return 100; From 1a8a661b5656c2578add5a7344afabe1cacc1cbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:22:05 +0100 Subject: [PATCH 233/316] [rtttl] Use register_apply_action for rtttl.play and rtttl.stop (#19482) --- esphome/components/rtttl/__init__.py | 46 +++------------------------- esphome/components/rtttl/rtttl.h | 16 ---------- tests/components/rtttl/common.yaml | 14 +++++++++ 3 files changed, 19 insertions(+), 57 deletions(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index b6c4183586..4da64a8304 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,8 +6,6 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -20,8 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -PlayAction = rtttl_ns.class_("PlayAction", automation.Action) -StopAction = rtttl_ns.class_("StopAction", automation.Action) IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -101,9 +97,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "rtttl.play", - PlayAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), @@ -111,43 +106,21 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_RTTTL, ), - synchronous=True, + automation.ApplyField(CONF_RTTTL, "play", cg.std_string), ) -async def rtttl_play_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "rtttl.stop", - StopAction, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def rtttl_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_condition( +automation.register_parented_condition( "rtttl.is_playing", IsPlayingCondition, cv.Schema( @@ -156,12 +129,3 @@ async def rtttl_stop_to_code( } ), ) -async def rtttl_is_playing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 256bdce5f2..0bf2fce70f 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -116,22 +116,6 @@ class Rtttl final : public Component { #endif }; -template class PlayAction final : public Action { - public: - PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { this->rtttl_->play(this->value_.value(x...)); } - - protected: - Rtttl *rtttl_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index a4d8f951f4..87c1eebdf0 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -19,6 +19,19 @@ esphome: - rtttl.play: 'test_only_o:o=5:c' - rtttl.play: 'test_only_b:b=100:c' - rtttl.play: 'test_empty::c' + # Long form with an explicit id, a lambda song, and the is_playing condition + - rtttl.play: + id: rtttl_player + rtttl: 'test_long_form:d=8,o=5,b=100:c' + - rtttl.play: + rtttl: !lambda return std::string("test_lambda:d=8,o=5,b=100:c"); + - if: + condition: + rtttl.is_playing: + id: rtttl_player + then: + - rtttl.stop: + id: rtttl_player output: - platform: ${output_platform} @@ -28,6 +41,7 @@ output: max_power: 0.5 rtttl: + id: rtttl_player output: rtttl_output on_finished_playback: - then: From 3da75331598d15a66f863516df8a504e539ca5db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:24:30 +0100 Subject: [PATCH 234/316] [remote_base] Generate the gobox code as uint64_t (#19478) --- esphome/components/remote_base/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index bf8707ff1e..e542ba3ed8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1149,7 +1149,7 @@ def gobox_dumper(var, config): @register_action("gobox", GoboxAction, GOBOX_SCHEMA) async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.int_) + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) cg.add(var.set_code(template_)) From 26abc1325efa7016a8480dc0cdce42042e871b93 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 23 Sep 2026 13:48:39 +0100 Subject: [PATCH 235/316] [nrf52] disable USB if not configured by esphome to save power (#19493) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 8 ++++++++ esphome/components/zephyr/__init__.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index aeeaba0c11..be8768fd15 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -428,6 +428,10 @@ async def to_code(config: ConfigType) -> None: ) zephyr_add_prj_conf("REBOOT", True) + # some boards enable USB by default. + # disable it to prevent extra current consumption. + zephyr_add_prj_conf("USB_DEVICE_STACK", False, False) + @coroutine_with_priority(CoroPriority.DIAGNOSTICS) async def _dfu_to_code(dfu_config): @@ -436,6 +440,10 @@ async def _dfu_to_code(dfu_config): if CONF_RESET_PIN in dfu_config: pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) cg.add(var.set_reset_pin(pin)) + + # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. + zephyr_add_prj_conf("USB_DEVICE_STACK", True) + zephyr_add_prj_conf("USB_CDC_ACM", True) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) await cg.register_component(var, dfu_config) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 29ba0b9ed2..2bcdf0380c 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -176,7 +176,10 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: - if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF][""]: + need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( + "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) + )[0] + if need_cdc_cb: var = cg.new_Pvariable(config[CONF_CDC_ACM]) await cg.register_component(var, {}) From 93df1e125970a93bc710c9d9b48ea29048bc6cbc Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 12:29:23 -0300 Subject: [PATCH 236/316] [gree] Remove the traits() override now handled by climate_ir (#19484) --- esphome/components/gree/gree.cpp | 11 ----------- esphome/components/gree/gree.h | 1 - tests/components/gree/__init__.py | 6 ++++++ tests/components/gree/gree_test.cpp | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 tests/components/gree/__init__.py create mode 100644 tests/components/gree/gree_test.cpp diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 634a2d33b5..6add618ecb 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,17 +5,6 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; -climate::ClimateTraits GreeClimate::traits() { - auto t = climate_ir::ClimateIR::traits(); - // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. - if (!this->supports_heat_) { - auto modes = t.get_supported_modes(); - modes.erase(climate::CLIMATE_MODE_HEAT_COOL); - t.set_supported_modes(modes); - } - return t; -} - void GreeClimate::set_model(Model model) { if (model == GREE_YAN) { // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 2f87926942..c261629573 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -94,7 +94,6 @@ class GreeClimate final : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; - climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); diff --git a/tests/components/gree/__init__.py b/tests/components/gree/__init__.py new file mode 100644 index 0000000000..8e15f6d479 --- /dev/null +++ b/tests/components/gree/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/gree/gree_test.cpp b/tests/components/gree/gree_test.cpp new file mode 100644 index 0000000000..9a02c60b39 --- /dev/null +++ b/tests/components/gree/gree_test.cpp @@ -0,0 +1,24 @@ +#include +#include "esphome/components/gree/gree.h" + +namespace esphome::gree::testing { + +TEST(GreeClimateTest, HeatCoolHiddenWithoutHeatByDefault) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.get_traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(GreeClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +} // namespace esphome::gree::testing From 9c5479287ed32acbf91aada89408c78599430de5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:29:47 +0000 Subject: [PATCH 237/316] Bump bundled esphome-device-builder to 1.15.0 (#19526) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a177ad006..d94a7ce41a 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.14.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.15.0 RUN \ platformio settings set enable_telemetry No \ From b36c6bd7cae365ba668c48b0b1139abe76afeed0 Mon Sep 17 00:00:00 2001 From: myztillx <33730898+myztillx@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:58:06 -0400 Subject: [PATCH 238/316] [dfplayer] Add set_current_track_repeat action (#9186) Co-authored-by: J. Nick Koston --- esphome/components/dfplayer/__init__.py | 29 ++++++++++++++++++++++++ esphome/components/dfplayer/dfplayer.cpp | 7 ++++++ esphome/components/dfplayer/dfplayer.h | 1 + tests/components/dfplayer/common.yaml | 9 ++++++++ 4 files changed, 46 insertions(+) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index bb18e6ba8c..afe8c72675 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import uart @@ -16,6 +18,7 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" +CONF_ENABLE = "enable" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" @@ -375,6 +378,32 @@ async def dfplayer_random_to_code(config, action_id, template_arg, args): return var +def _default_enable(value: Any) -> Any: + """Fill in ``enable: true`` for a bare action or a mapping that only picks the player. + + Done before ``maybe_simple_value`` so neither form is wrapped as the ``enable`` value. + """ + if value is None or isinstance(value, dict): + return {CONF_ENABLE: True, **(value or {})} + return value + + +automation.register_apply_action( + "dfplayer.set_current_track_repeat", + cv.All( + _default_enable, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(DFPlayer), + cv.Optional(CONF_ENABLE, default=True): cv.templatable(cv.boolean), + }, + key=CONF_ENABLE, + ), + ), + automation.ApplyField(CONF_ENABLE, "set_current_track_repeat", cg.bool_), +) + + @automation.register_condition( "dfplayer.is_playing", DFPlayerIsPlayingCondition, diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index f81d1cd1b6..1195f595c8 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -102,6 +102,13 @@ void DFPlayer::random() { this->send_cmd_(0x18); } +void DFPlayer::set_current_track_repeat(bool enable) { + uint16_t arg = enable ? 0x00 : 0x01; + ESP_LOGD(TAG, "Setting current track repeat to %s", + enable ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); + this->send_cmd_(0x19, arg); +} + void DFPlayer::play_folder(uint16_t folder, uint16_t file) { ESP_LOGD(TAG, "Playing file %d in folder %d", file, folder); if (folder < 100 && file < 256) { diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 1db6b394c5..66cf929408 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -46,6 +46,7 @@ class DFPlayer final : public uart::UARTDevice, public Component { void pause(); void stop(); void random(); + void set_current_track_repeat(bool enable); bool is_playing() { return is_playing_; } void dump_config() override; diff --git a/tests/components/dfplayer/common.yaml b/tests/components/dfplayer/common.yaml index 5d2540c275..940f07a2ed 100644 --- a/tests/components/dfplayer/common.yaml +++ b/tests/components/dfplayer/common.yaml @@ -25,8 +25,17 @@ esphome: - dfplayer.volume_up - dfplayer.volume_down - dfplayer.sleep + - dfplayer.set_current_track_repeat + - dfplayer.set_current_track_repeat: true + - dfplayer.set_current_track_repeat: + enable: false + - dfplayer.set_current_track_repeat: + enable: !lambda return true; + - dfplayer.set_current_track_repeat: + id: dfplayer_test dfplayer: + id: dfplayer_test on_finished_playback: then: if: From 35607e99a76f4c3340292cdbfe8e177974216871 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 13:18:41 -0300 Subject: [PATCH 239/316] [mitsubishi] Use the climate_ir traits() for supported modes (#19485) --- esphome/components/mitsubishi/mitsubishi.cpp | 40 ------------ esphome/components/mitsubishi/mitsubishi.h | 19 ++++-- tests/components/mitsubishi/__init__.py | 6 ++ .../components/mitsubishi/mitsubishi_test.cpp | 65 +++++++++++++++++++ 4 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 tests/components/mitsubishi/__init__.py create mode 100644 tests/components/mitsubishi/mitsubishi_test.cpp diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index d108ea4621..d7c4e7f1a1 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -53,46 +53,6 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; -climate::ClimateTraits MitsubishiClimate::traits() { - auto traits = climate::ClimateTraits(); - if (this->sensor_ != nullptr) { - traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } - traits.set_visual_min_temperature(MITSUBISHI_TEMP_MIN); - traits.set_visual_max_temperature(MITSUBISHI_TEMP_MAX); - traits.set_visual_temperature_step(1.0f); - traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); - - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - - if (this->supports_cool_ && this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); - - // Default to only 3 levels in ESPHome even if most unit supports 4. The 3rd level is not used. - traits.set_supported_fan_modes( - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}); - if (this->fan_mode_ == MITSUBISHI_FAN_Q4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_QUIET); - if (/*this->fan_mode_ == MITSUBISHI_FAN_5L ||*/ this->fan_mode_ >= MITSUBISHI_FAN_4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps - - traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); - - traits.set_supported_presets({climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, - climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}); - - return traits; -} - void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 657da900e2..5b61beac4e 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,12 +38,14 @@ enum VerticalDirection : uint8_t { VERTICAL_DIRECTION_DOWN = 0x28, }; +// Fan modes offered by every set_fan_mode option. +static constexpr climate::ClimateFanModeMask MITSUBISHI_BASE_FAN_MODES{ + climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}; + class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() - : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_QUIET}, + : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, MITSUBISHI_BASE_FAN_MODES, {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}, {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, @@ -54,7 +56,14 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; } + void set_fan_mode(SetFanMode fan_mode) { + this->fan_mode_ = fan_mode; + this->fan_modes_ = MITSUBISHI_BASE_FAN_MODES; + if (fan_mode == MITSUBISHI_FAN_Q4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); + if (fan_mode >= MITSUBISHI_FAN_4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; @@ -74,8 +83,6 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; - - climate::ClimateTraits traits() override; }; } // namespace esphome::mitsubishi diff --git a/tests/components/mitsubishi/__init__.py b/tests/components/mitsubishi/__init__.py new file mode 100644 index 0000000000..8e15f6d479 --- /dev/null +++ b/tests/components/mitsubishi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/mitsubishi/mitsubishi_test.cpp b/tests/components/mitsubishi/mitsubishi_test.cpp new file mode 100644 index 0000000000..3bc84a5db9 --- /dev/null +++ b/tests/components/mitsubishi/mitsubishi_test.cpp @@ -0,0 +1,65 @@ +#include +#include "esphome/components/mitsubishi/mitsubishi.h" + +namespace esphome::mitsubishi::testing { + +TEST(MitsubishiClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + MitsubishiClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(MitsubishiClimateTest, HeatCoolOverrideHiddenWithHeatAndCool) { + MitsubishiClimate climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); +} + +TEST(MitsubishiClimateTest, FanModesFor3Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_AUTO)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_LOW)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MEDIUM)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFor4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesForQuietAnd4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFollowTheLastSetFanMode) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); +} + +} // namespace esphome::mitsubishi::testing From f6e97b7adaa6225fd2d8296acab0e4771b487156 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 23 Sep 2026 18:19:09 +0200 Subject: [PATCH 240/316] [RX8025T] add RX8025T realtime clock (#13274) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/rx8025t/__init__.py | 0 esphome/components/rx8025t/rx8025t.cpp | 128 ++++++++++++++++++ esphome/components/rx8025t/rx8025t.h | 21 +++ esphome/components/rx8025t/time.py | 42 ++++++ tests/components/rx8025t/common.yaml | 12 ++ tests/components/rx8025t/test.esp32-idf.yaml | 4 + .../components/rx8025t/test.esp8266-ard.yaml | 4 + .../rx8025t/test.nrf52-adafruit.yaml | 4 + tests/components/rx8025t/test.rp2040-ard.yaml | 4 + 10 files changed, 220 insertions(+) create mode 100644 esphome/components/rx8025t/__init__.py create mode 100644 esphome/components/rx8025t/rx8025t.cpp create mode 100644 esphome/components/rx8025t/rx8025t.h create mode 100644 esphome/components/rx8025t/time.py create mode 100644 tests/components/rx8025t/common.yaml create mode 100644 tests/components/rx8025t/test.esp32-idf.yaml create mode 100644 tests/components/rx8025t/test.esp8266-ard.yaml create mode 100644 tests/components/rx8025t/test.nrf52-adafruit.yaml create mode 100644 tests/components/rx8025t/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index aba498c365..5c7063f0a5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -457,6 +457,7 @@ esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco +esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny diff --git a/esphome/components/rx8025t/__init__.py b/esphome/components/rx8025t/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp new file mode 100644 index 0000000000..fee2c83a5f --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -0,0 +1,128 @@ +#include "rx8025t.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://support.epson.biz/td/api/doc_check.php?dl=app_RX8025T + +namespace esphome::rx8025t { + +static constexpr uint8_t RX8025T_REG_SEC = 0x00; +static constexpr uint8_t RX8025T_REG_FLAG = 0x0E; +static constexpr uint8_t RX8025T_FLAG_VDET = 0x01; +static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; + +static const char *const TAG = "rx8025t"; + +constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } +constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } + +void RX8025TComponent::setup() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + this->mark_failed(); + return; + } + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - Loss of oscillator detected. Time may be invalid."); + } +} + +void RX8025TComponent::update() { this->read_time(); } + +void RX8025TComponent::dump_config() { + ESP_LOGCONFIG(TAG, "RX8025T:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + time::RealTimeClock::dump_config(); +} + +bool RX8025TComponent::read_flags_(uint8_t *flags) { + if (!this->read_byte(RX8025T_REG_FLAG, flags)) { + ESP_LOGE(TAG, "Can't read flag register."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + return true; +} + +void RX8025TComponent::read_time() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + + uint8_t date[7]; + if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + ESP_LOGE(TAG, "Can't read I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); + return; + } + + ESPTime rtc_time{ + .second = bcd2dec(date[0] & 0x7f), + .minute = bcd2dec(date[1] & 0x7f), + .hour = bcd2dec(date[2] & 0x3f), + .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), + .day_of_month = bcd2dec(date[4] & 0x3f), + .day_of_year = 1, + .month = bcd2dec(date[5] & 0x1f), + .year = static_cast(bcd2dec(date[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d VDET:%s", rtc_time.year, rtc_time.month, + rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, ONOFF(flags & RX8025T_FLAG_VDET)); + this->synchronize_epoch_(rtc_time.timestamp); +} + +void RX8025TComponent::write_time() { + auto now = this->utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + + uint8_t buff[7]; + buff[0] = dec2bcd(now.second); + buff[1] = dec2bcd(now.minute); + buff[2] = dec2bcd(now.hour); + buff[3] = 1 << (now.day_of_week - 1); + buff[4] = dec2bcd(now.day_of_month); + buff[5] = dec2bcd(now.month); + buff[6] = dec2bcd(now.year % 100); + if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { + ESP_LOGE(TAG, "Can't write I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); + + // Clear VLF and VDET flags via read-modify-write of flag register + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); + if (!this->write_byte(RX8025T_REG_FLAG, flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); +} + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/rx8025t.h b/esphome/components/rx8025t/rx8025t.h new file mode 100644 index 0000000000..9c53fe6971 --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8025t { + +class RX8025TComponent : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool read_flags_(uint8_t *flags); +}; + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py new file mode 100644 index 0000000000..e8ffe772ab --- /dev/null +++ b/esphome/components/rx8025t/time.py @@ -0,0 +1,42 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +rx8025t_ns = cg.esphome_ns.namespace("rx8025t") +RX8025TComponent = rx8025t_ns.class_( + "RX8025TComponent", time.RealTimeClock, i2c.I2CDevice +) +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8025TComponent), + } +).extend(i2c.i2c_device_schema(0x32)) + + +for _name, _call in ( + ("rx8025t.write_time", "write_time()"), + ("rx8025t.read_time", "read_time()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8025TComponent), + } + ), + automation.ApplyCall(_call), + ) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/tests/components/rx8025t/common.yaml b/tests/components/rx8025t/common.yaml new file mode 100644 index 0000000000..b8c7b6a1a2 --- /dev/null +++ b/tests/components/rx8025t/common.yaml @@ -0,0 +1,12 @@ +esphome: + on_boot: + then: + - rx8025t.write_time: rx8025t_time + - rx8025t.read_time: + id: rx8025t_time + +time: + - platform: rx8025t + i2c_id: i2c_bus + id: rx8025t_time + update_interval: never diff --git a/tests/components/rx8025t/test.esp32-idf.yaml b/tests/components/rx8025t/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/rx8025t/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.esp8266-ard.yaml b/tests/components/rx8025t/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/rx8025t/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.nrf52-adafruit.yaml b/tests/components/rx8025t/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/rx8025t/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.rp2040-ard.yaml b/tests/components/rx8025t/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/rx8025t/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From cf3552b46ef88c077e732784038581a2d5e4cebe Mon Sep 17 00:00:00 2001 From: ngolf <74095787+ngolf@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:21:25 +0100 Subject: [PATCH 241/316] [pid] Pid climate add set deadband parameters action (#14730) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pid/climate.py | 99 ++++++++++++------- esphome/components/pid/pid_climate.cpp | 11 +++ esphome/components/pid/pid_climate.h | 33 +------ esphome/components/pid/sensor/__init__.py | 72 +++++++++++--- .../pid/sensor/pid_climate_sensor.cpp | 40 +++++--- .../pid/sensor/pid_climate_sensor.h | 5 + tests/components/pid/__init__.py | 5 + tests/components/pid/common.yaml | 29 ++++++ .../pid/test_deadband_thresholds.cpp | 41 ++++++++ 9 files changed, 236 insertions(+), 99 deletions(-) create mode 100644 tests/components/pid/__init__.py create mode 100644 tests/components/pid/test_deadband_thresholds.cpp diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 4945547f2e..11a6fd5e6b 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,19 +3,13 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR -from esphome.core import ID +from esphome.core import ID, Lambda from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action) -PIDResetIntegralTermAction = pid_ns.class_( - "PIDResetIntegralTermAction", automation.Action -) -PIDSetControlParametersAction = pid_ns.class_( - "PIDSetControlParametersAction", automation.Action -) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -43,6 +37,24 @@ CONF_KP_MULTIPLIER = "kp_multiplier" CONF_KI_MULTIPLIER = "ki_multiplier" CONF_KD_MULTIPLIER = "kd_multiplier" + +def _validate_thresholds(config: ConfigType) -> ConfigType: + # Same rule as PIDClimate::set_deadband_thresholds; equal is allowed since 0/0 is the default. + if config[CONF_THRESHOLD_LOW] > config[CONF_THRESHOLD_HIGH]: + raise cv.Invalid( + f"{CONF_THRESHOLD_LOW} must not be greater than {CONF_THRESHOLD_HIGH}" + ) + return config + + +def _validate_threshold_action(config: ConfigType) -> ConfigType: + threshold_low = config[CONF_THRESHOLD_LOW] + threshold_high = config[CONF_THRESHOLD_HIGH] + if isinstance(threshold_low, Lambda) or isinstance(threshold_high, Lambda): + return config + return _validate_thresholds(config) + + CONFIG_SCHEMA = cv.All( climate.climate_schema(PIDClimate).extend( { @@ -51,7 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_DEFAULT_TARGET_TEMPERATURE): cv.temperature, cv.Optional(CONF_COOL_OUTPUT): cv.use_id(output.FloatOutput), cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput), - cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema( + cv.Optional(CONF_DEADBAND_PARAMETERS): cv.All( { cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta, cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta, @@ -61,7 +73,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1 ): cv.positive_not_null_int, - } + }, + _validate_thresholds, ), cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema( { @@ -134,24 +147,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE])) -@automation.register_action( +automation.register_apply_action( "climate.pid.reset_integral_term", - PIDResetIntegralTermAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), } ), - synchronous=True, + automation.ApplyCall("reset_integral_term()"), ) -async def pid_reset_integral_term( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -185,9 +189,8 @@ async def esp8266_set_frequency_to_code( return var -@automation.register_action( +automation.register_apply_action( "climate.pid.set_control_parameters", - PIDSetControlParametersAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -196,24 +199,44 @@ async def esp8266_set_frequency_to_code( cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + automation.ApplyField(CONF_KD, "set_kd", cg.float_), ) -async def set_control_parameters( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_) - cg.add(var.set_kp(kp_template_)) - ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_) - cg.add(var.set_ki(ki_template_)) +automation.register_apply_action( + "climate.pid.set_deadband_control_parameters_multipliers", + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + # kp_multiplier is required for compatibility with the original action API; + # ki_multiplier and kd_multiplier are optional overrides. + cv.Required(CONF_KP_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KI_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KD_MULTIPLIER): cv.templatable(cv.float_), + } + ), + automation.ApplyField(CONF_KP_MULTIPLIER, "set_kp_multiplier", cg.float_), + automation.ApplyField(CONF_KI_MULTIPLIER, "set_ki_multiplier", cg.float_), + automation.ApplyField(CONF_KD_MULTIPLIER, "set_kd_multiplier", cg.float_), +) - kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_) - cg.add(var.set_kd(kd_template_)) - return var +automation.register_apply_action( + "climate.pid.set_deadband_threshold_parameters", + automation.maybe_simple_id( + cv.All( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + cv.Required(CONF_THRESHOLD_HIGH): cv.templatable(cv.temperature_delta), + cv.Required(CONF_THRESHOLD_LOW): cv.templatable(cv.temperature_delta), + }, + _validate_threshold_action, + ) + ), + automation.ApplyCall( + "set_deadband_thresholds({}, {})", + ((CONF_THRESHOLD_LOW, cg.float_), (CONF_THRESHOLD_HIGH, cg.float_)), + ), +) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 8c9231fda6..b386716780 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -5,6 +5,17 @@ namespace esphome::pid { static const char *const TAG = "pid.climate"; +bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { + if (threshold_low > threshold_high) { + ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high); + return false; + } + + this->set_threshold_low(threshold_low); + this->set_threshold_high(threshold_high); + return true; +} + void PIDClimate::setup() { this->sensor_->add_on_state_callback([this](float state) { // only publish if state/current temperature has changed in two digits of precision diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 7269709ab9..e8701c99a4 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -26,6 +26,7 @@ class PIDClimate final : public climate::Climate, public Component { void set_kd(float kd) { controller_.kd_ = kd; } void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; } void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; } + bool set_deadband_thresholds(float threshold_low, float threshold_high); void set_output_samples(int in) { controller_.output_samples_ = in; } void set_derivative_samples(int in) { controller_.derivative_samples_ = in; @@ -131,36 +132,4 @@ template class PIDAutotuneAction final : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction final : public Action { - public: - PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->reset_integral_term(); } - - protected: - PIDClimate *parent_; -}; - -template class PIDSetControlParametersAction final : public Action { - public: - PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { - auto kp = this->kp_.value(x...); - auto ki = this->ki_.value(x...); - auto kd = this->kd_.value(x...); - - this->parent_->set_kp(kp); - this->parent_->set_ki(ki); - this->parent_->set_kd(kd); - } - - protected: - TEMPLATABLE_VALUE(float, kp) - TEMPLATABLE_VALUE(float, ki) - TEMPLATABLE_VALUE(float, kd) - - PIDClimate *parent_; -}; - } // namespace esphome::pid diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index 94d641de47..242a2440f8 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -2,7 +2,15 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.const import ( + CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, + ICON_GAUGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, +) from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -11,22 +19,55 @@ PIDClimateSensor = pid_ns.class_("PIDClimateSensor", sensor.Sensor, cg.Component PIDClimateSensorType = pid_ns.enum("PIDClimateSensorType") PID_CLIMATE_SENSOR_TYPES = { - "RESULT": PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, - "ERROR": PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, - "PROPORTIONAL": PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, - "INTEGRAL": PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, - "DERIVATIVE": PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, - "HEAT": PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, - "COOL": PIDClimateSensorType.PID_SENSOR_TYPE_COOL, - "KP": PIDClimateSensorType.PID_SENSOR_TYPE_KP, - "KI": PIDClimateSensorType.PID_SENSOR_TYPE_KI, - "KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD, + "RESULT": (PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, UNIT_PERCENT), + "ERROR": (PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, UNIT_PERCENT), + "PROPORTIONAL": (PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, UNIT_PERCENT), + "INTEGRAL": (PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, UNIT_PERCENT), + "DERIVATIVE": (PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, UNIT_PERCENT), + "HEAT": (PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, UNIT_PERCENT), + "COOL": (PIDClimateSensorType.PID_SENSOR_TYPE_COOL, UNIT_PERCENT), + "KP": (PIDClimateSensorType.PID_SENSOR_TYPE_KP, UNIT_PERCENT), + "KI": (PIDClimateSensorType.PID_SENSOR_TYPE_KI, UNIT_PERCENT), + "KD": (PIDClimateSensorType.PID_SENSOR_TYPE_KD, UNIT_PERCENT), + "DEADBAND_THRESHOLD_HIGH": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + UNIT_CELSIUS, + ), + "DEADBAND_THRESHOLD_LOW": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + UNIT_CELSIUS, + ), + "KP_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KI_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KD_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), } -CONFIG_SCHEMA = ( +PID_CLIMATE_SENSOR_ENUMS = { + sensor_type: sensor_config[0] + for sensor_type, sensor_config in PID_CLIMATE_SENSOR_TYPES.items() +} + + +def set_default_unit_of_measurement(config: ConfigType) -> ConfigType: + sensor_type = config[CONF_TYPE] + config.setdefault( + CONF_UNIT_OF_MEASUREMENT, PID_CLIMATE_SENSOR_TYPES[sensor_type][1] + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( PIDClimateSensor, - unit_of_measurement=UNIT_PERCENT, icon=ICON_GAUGE, accuracy_decimals=1, state_class=STATE_CLASS_MEASUREMENT, @@ -34,10 +75,11 @@ CONFIG_SCHEMA = ( .extend( { cv.GenerateID(CONF_CLIMATE_ID): cv.use_id(PIDClimate), - cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_TYPES, upper=True), + cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_ENUMS, upper=True), } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + set_default_unit_of_measurement, ) diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index 4e963168e6..afc77efde3 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -14,43 +14,55 @@ void PIDClimateSensor::update_from_parent_() { float value; switch (this->type_) { case PID_SENSOR_TYPE_RESULT: - value = this->parent_->get_output_value(); + value = this->parent_->get_output_value() * 100.0f; break; case PID_SENSOR_TYPE_ERROR: - value = this->parent_->get_error_value(); + value = this->parent_->get_error_value() * 100.0f; break; case PID_SENSOR_TYPE_PROPORTIONAL: - value = this->parent_->get_proportional_term(); + value = this->parent_->get_proportional_term() * 100.0f; break; case PID_SENSOR_TYPE_INTEGRAL: - value = this->parent_->get_integral_term(); + value = this->parent_->get_integral_term() * 100.0f; break; case PID_SENSOR_TYPE_DERIVATIVE: - value = this->parent_->get_derivative_term(); + value = this->parent_->get_derivative_term() * 100.0f; break; case PID_SENSOR_TYPE_HEAT: - value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_COOL: - value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_KP: value = this->parent_->get_kp(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KI: value = this->parent_->get_ki(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KD: value = this->parent_->get_kd(); - this->publish_state(value); - return; + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH: + value = this->parent_->get_threshold_high(); + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW: + value = this->parent_->get_threshold_low(); + break; + case PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER: + value = this->parent_->get_kp_multiplier(); + break; + case PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER: + value = this->parent_->get_ki_multiplier(); + break; + case PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER: + value = this->parent_->get_kd_multiplier(); + break; default: value = NAN; break; } - this->publish_state(value * 100.0f); + this->publish_state(value); } void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); } diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index b62d597780..1d208c44f8 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -16,6 +16,11 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KP, PID_SENSOR_TYPE_KI, PID_SENSOR_TYPE_KD, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, }; class PIDClimateSensor final : public sensor::Sensor, public Component { diff --git a/tests/components/pid/__init__.py b/tests/components/pid/__init__.py new file mode 100644 index 0000000000..f8b7080743 --- /dev/null +++ b/tests/components/pid/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["climate", "output", "sensor"] diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775f..c78ad8fc1e 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -12,6 +12,15 @@ esphome: kp: 0.0 ki: 0.0 kd: 0.0 + - climate.pid.set_deadband_control_parameters_multipliers: + id: pid_climate + kp_multiplier: 0.0 + ki_multiplier: 0.0 + kd_multiplier: 0.0 + - climate.pid.set_deadband_threshold_parameters: + id: pid_climate + threshold_high: 0.5°C + threshold_low: !lambda return -0.5f; - climate.pid.reset_integral_term: pid_climate output: @@ -30,6 +39,26 @@ sensor: } return 0.0; update_interval: 60s + - platform: pid + id: pid_kp_multiplier + climate_id: pid_climate + type: KP_DEADBAND_MULTIPLIER + - platform: pid + id: pid_ki_multiplier + climate_id: pid_climate + type: KI_DEADBAND_MULTIPLIER + - platform: pid + id: pid_kd_multiplier + climate_id: pid_climate + type: KD_DEADBAND_MULTIPLIER + - platform: pid + id: pid_threshold_high + climate_id: pid_climate + type: DEADBAND_THRESHOLD_HIGH + - platform: pid + id: pid_threshold_low + climate_id: pid_climate + type: DEADBAND_THRESHOLD_LOW climate: - platform: pid diff --git a/tests/components/pid/test_deadband_thresholds.cpp b/tests/components/pid/test_deadband_thresholds.cpp new file mode 100644 index 0000000000..ba37a16d8d --- /dev/null +++ b/tests/components/pid/test_deadband_thresholds.cpp @@ -0,0 +1,41 @@ +#include + +#include "esphome/components/pid/pid_climate.h" + +namespace esphome::pid { + +TEST(PIDClimateDeadbandThresholds, InvalidThresholdsDoNotChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_FALSE(climate.set_deadband_thresholds(2.0f, 1.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -1.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 1.0f); +} + +TEST(PIDClimateDeadbandThresholds, ValidThresholdsChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(-2.0f, 0.5f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -2.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.5f); +} + +TEST(PIDClimateDeadbandThresholds, EqualThresholdsDisableDeadband) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(0.0f, 0.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), 0.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.0f); + EXPECT_FALSE(climate.in_deadband()); +} + +} // namespace esphome::pid From d78d640abd1b009398e7604bbf3d33801b4d826f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 23 Sep 2026 09:33:00 -0700 Subject: [PATCH 242/316] [modbus] Drop a deferred server reply when another frame arrives first (#19534) --- esphome/components/modbus/modbus.cpp | 7 ++ .../uart_mock_modbus_server_injected.yaml | 68 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 62 ++++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 037901a873..eae418ba3d 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -203,6 +203,12 @@ void ModbusClientHub::parse_modbus_frames() { void ModbusServerHub::parse_modbus_frames() { while (!this->rx_buffer_.empty()) { + if (this->deferred_payload_len_ != 0) { + // Another frame arrived before the deferred reply went out, so the client has moved on. + this->cancel_timeout("deferred_send"); + ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]); + this->deferred_payload_len_ = 0; + } size_t size = this->rx_buffer_.size(); ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); bool retry_as_client = false; @@ -1213,6 +1219,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); + this->deferred_payload_len_ = 0; if (!this->send_frame_(frame)) { ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); } diff --git a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml index 2cd1c610f1..8b2113ccd9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -26,6 +26,20 @@ uart_mock: rx_timeout: 2 auto_start: false debug: + # Each burst-case reply from device 1 (single-register FC 0x03, told apart by its register + # value) fires its own sensor when it reaches the wire. One sensor per reply, because the API + # merges updates to the same entity that land within its batching window. + on_tx: + - then: + - lambda: |- + if (data.size() != 7 || data[0] != 0x01 || data[1] != 0x03 || data[3] != 0x00) + return; + switch (data[4]) { + case 0xA1: id(burst_tx_a).publish_state(1); break; + case 0xB2: id(burst_tx_b).publish_state(1); break; + case 0xC3: id(burst_tx_before_peer).publish_state(1); break; + case 0xD4: id(burst_tx_probe).publish_state(1); break; + } injections: - delay: 100ms inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) @@ -52,6 +66,21 @@ uart_mock: - delay: 100ms inject_rx: [0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55] + # Two reads of device 1 (regs 0x0B then 0x0C) in one injection so both land in the rx buffer + # together. The reply to 0x0B is deferred because 0x0C is still queued behind it, and must be + # dropped once 0x0C is parsed: only the reply to 0x0C may reach the wire (burst_read_a/b). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0B, 0x00, 0x01, 0xF5, 0xC8, + 0x01, 0x03, 0x00, 0x0C, 0x00, 0x01, 0x44, 0x09] + # Read of device 1 (reg 0x0D) followed in the same injection by a read of device 2. The client + # has moved on to another device, so the deferred reply to 0x0D must never be sent + # (burst_read_before_peer). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0D, 0x00, 0x01, 0x15, 0xC9, + 0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] + # Plain read of device 1 (reg 0x0E) whose reply on the wire marks the burst cases as settled. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0E, 0x00, 0x01, 0xE5, 0xC9] globals: - id: stored_1 @@ -110,6 +139,24 @@ modbus_server: read_lambda: |- id(read_after_peer_timeout).publish_state(1); return 1; + - address: 0x0B + value_type: U_WORD + read_lambda: |- + id(burst_read_a).publish_state(1); + return 0xA1; + - address: 0x0C + value_type: U_WORD + read_lambda: |- + id(burst_read_b).publish_state(1); + return 0xB2; + - address: 0x0D + value_type: U_WORD + read_lambda: |- + id(burst_read_before_peer).publish_state(1); + return 0xC3; + - address: 0x0E + value_type: U_WORD + read_lambda: return 0xD4; sensor: - platform: template @@ -136,6 +183,27 @@ sensor: - platform: template name: "rw_read_3" id: rw_read_3 + - platform: template + name: "burst_read_a" + id: burst_read_a + - platform: template + name: "burst_read_b" + id: burst_read_b + - platform: template + name: "burst_read_before_peer" + id: burst_read_before_peer + - platform: template + name: "burst_tx_a" + id: burst_tx_a + - platform: template + name: "burst_tx_b" + id: burst_tx_b + - platform: template + name: "burst_tx_before_peer" + id: burst_tx_before_peer + - platform: template + name: "burst_tx_probe" + id: burst_tx_probe button: - platform: template diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 36aa9a9668..1b877f5948 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -260,11 +260,71 @@ async def test_uart_mock_modbus_server_read_write( api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) - # The FC 0x17 injections fire last, behind four earlier 100ms delays + # The FC 0x17 injections fire behind four earlier 100ms delays await tracker.await_all(futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_burst( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a server reply deferred behind a queued frame is dropped. + + Two requests are injected as one chunk so both sit in the rx buffer at + once. The reply to the first is deferred because the second is still + queued, and must be discarded once the second frame is parsed: + * device 1 reg 0x0B then device 1 reg 0x0C -- only the 0x0C reply is sent; + * device 1 reg 0x0D then a device 2 request -- nothing is sent. + The fixture's on_tx hook fires a sensor per burst reply that reaches the + wire, and a final plain read marks both cases settled once its reply is + seen. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + [ + "burst_read_a", + "burst_read_b", + "burst_read_before_peer", + "burst_tx_a", + "burst_tx_b", + "burst_tx_before_peer", + "burst_tx_probe", + ] + ) + futures = tracker.expect_all( + { + "burst_read_a": 1, + "burst_read_b": 1, + "burst_read_before_peer": 1, + "burst_tx_b": 1, + "burst_tx_probe": 1, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # Every request is parsed and served by its read_lambda regardless of + # whether its reply reaches the wire. + await tracker.await_all(futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + assert not tracker.sensor_states["burst_tx_a"], ( + "reply to reg 0x0B must be dropped, a later request was queued behind it" + ) + assert not tracker.sensor_states["burst_tx_before_peer"], ( + "reply to reg 0x0D must be dropped, the client moved on to device 2" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_read_write_invalid( yaml_config: str, From 70f8ec73c9db212069d912252653b377805792b9 Mon Sep 17 00:00:00 2001 From: dalklein <49771139+dalklein@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:40:01 -0400 Subject: [PATCH 243/316] [modbus] Log the discarded bytes, not just how many (#19428) Co-authored-by: Claude Opus 5 --- esphome/components/modbus/modbus.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eae418ba3d..0cf57ef7aa 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1241,6 +1241,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t if (warn) { ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; + ESP_LOGV(TAG, " discarded: %s%s", format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), bytes), + bytes > MODBUS_MAX_LOG_BYTES ? LOG_STR_LITERAL(" ...") : LOG_STR_LITERAL("")); +#endif } else { ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); From ce6a66959a371f8eb26b81b867cbe270c43c7fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:41:47 +0100 Subject: [PATCH 244/316] [sprinkler] Use register_apply_action for the setter actions (#19471) --- esphome/components/sprinkler/__init__.py | 72 ++++------------------- esphome/components/sprinkler/automation.h | 68 --------------------- tests/components/sprinkler/common.yaml | 12 ++++ 3 files changed, 24 insertions(+), 128 deletions(-) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index efa5b0bf15..21863e2621 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -66,14 +66,9 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -SetDividerAction = sprinkler_ns.class_("SetDividerAction", automation.Action) -SetMultiplierAction = sprinkler_ns.class_("SetMultiplierAction", automation.Action) -QueueValveAction = sprinkler_ns.class_("QueueValveAction", automation.Action) ClearQueuedValvesAction = sprinkler_ns.class_( "ClearQueuedValvesAction", automation.Action ) -SetRepeatAction = sprinkler_ns.class_("SetRepeatAction", automation.Action) -SetRunDurationAction = sprinkler_ns.class_("SetRunDurationAction", automation.Action) StartFromQueueAction = sprinkler_ns.class_("StartFromQueueAction", automation.Action) StartFullCycleAction = sprinkler_ns.class_("StartFullCycleAction", automation.Action) StartSingleValveAction = sprinkler_ns.class_( @@ -418,80 +413,37 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +_VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) + +automation.register_apply_action( "sprinkler.set_divider", - SetDividerAction, SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), ) -async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DIVIDER], args, cg.uint32) - cg.add(var.set_divider(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_multiplier", - SetMultiplierAction, SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), ) -async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MULTIPLIER], args, cg.float_) - cg.add(var.set_multiplier(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.queue_valve", - QueueValveAction, SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - synchronous=True, + automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), ) -async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_repeat", - SetRepeatAction, SPRINKLER_ACTION_REPEAT_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), ) -async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.uint32) - cg.add(var.set_repeat(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_valve_run_duration", - SetRunDurationAction, SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - synchronous=True, + automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), ) -async def sprinkler_set_valve_run_duration_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var @automation.register_action( diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index beeec96b98..14d9bd20ec 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,46 +6,6 @@ namespace esphome::sprinkler { -template class SetDividerAction final : public Action { - public: - explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, divider) - - void play(const Ts &...x) override { this->sprinkler_->set_divider(this->divider_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetMultiplierAction final : public Action { - public: - explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(float, multiplier) - - void play(const Ts &...x) override { this->sprinkler_->set_multiplier(this->multiplier_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class QueueValveAction final : public Action { - public: - explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->queue_valve(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,34 +16,6 @@ template class ClearQueuedValvesAction final : public Action class SetRepeatAction final : public Action { - public: - explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, repeat) - - void play(const Ts &...x) override { this->sprinkler_->set_repeat(this->repeat_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRunDurationAction final : public Action { - public: - explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->set_valve_run_duration(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f524..890cf9fd88 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -31,6 +31,18 @@ esphome: id: yard_sprinkler_ctrlr valve_number: 0 run_duration: 600s + # Templated values, including both keys of the two-key calls + - sprinkler.set_multiplier: + id: yard_sprinkler_ctrlr + multiplier: !lambda return 1.5f; + - sprinkler.queue_valve: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 300; + - sprinkler.set_valve_run_duration: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 120; switch: - platform: template From 487ef61d0afa33f16d07dd910cf004524e4570c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:47:31 +0100 Subject: [PATCH 245/316] [nextion] Use register_apply_action for set_brightness and the publish actions (#19529) --- esphome/components/nextion/automation.h | 96 ------------------- .../nextion/binary_sensor/__init__.py | 30 ++---- esphome/components/nextion/display.py | 19 +--- esphome/components/nextion/sensor/__init__.py | 30 ++---- esphome/components/nextion/switch/__init__.py | 30 ++---- .../nextion/text_sensor/__init__.py | 30 ++---- tests/components/nextion/common.yaml | 1 + 7 files changed, 40 insertions(+), 196 deletions(-) delete mode 100644 esphome/components/nextion/automation.h diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h deleted file mode 100644 index 0226c65be6..0000000000 --- a/esphome/components/nextion/automation.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/string_ref.h" - -#include "nextion.h" - -namespace esphome::nextion { - -template class NextionSetBrightnessAction final : public Action { - public: - explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, brightness) - - void play(const Ts &...x) override { - this->component_->set_brightness(this->brightness_.value(x...)); - this->component_->set_backlight_brightness(this->brightness_.value(x...)); - } - - void set_brightness(std::function brightness) { this->brightness_ = brightness; } - - protected: - Nextion *component_; -}; - -template class NextionPublishFloatAction final : public Action { - public: - explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishTextAction final : public Action { - public: - explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(const char *, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishBoolAction final : public Action { - public: - explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -} // namespace esphome::nextion diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 29f5bdaea7..6c390e1c6e 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -18,10 +18,6 @@ NextionBinarySensor = nextion_ns.class_( "NextionBinarySensor", binary_sensor.BinarySensor, cg.PollingComponent ) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( binary_sensor.binary_sensor_schema(NextionBinarySensor) .extend( @@ -57,9 +53,8 @@ async def to_code(config): cg.add(hub.register_binarysensor_component(var)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionBinarySensor), @@ -70,19 +65,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index a5894bdaf7..73a56e0a42 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -56,11 +56,6 @@ def AUTO_LOAD() -> list[str]: return base -NextionSetBrightnessAction = nextion_ns.class_( - "NextionSetBrightnessAction", automation.Action -) - - def _deprecated_dump_device_info(value): _LOGGER.warning( "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " @@ -160,9 +155,8 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "display.nextion.set_brightness", - NextionSetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Nextion), @@ -170,16 +164,9 @@ CONFIG_SCHEMA = cv.All( }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_BRIGHTNESS, "set_backlight_brightness", cg.float_), ) -async def nextion_set_brightness_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) - cg.add(var.set_brightness(template_)) - - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 61cb42e62c..6c93286656 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -21,10 +21,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSensor = nextion_ns.class_("NextionSensor", sensor.Sensor, cg.PollingComponent) -NextionPublishFloatAction = nextion_ns.class_( - "NextionPublishFloatAction", automation.Action -) - def CheckWaveID(value): value = cv.int_(value) @@ -97,9 +93,8 @@ async def to_code(config): cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) -@automation.register_action( +automation.register_apply_action( "sensor.nextion.publish", - NextionPublishFloatAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSensor), @@ -110,19 +105,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.float_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 29749ecab0..5053c30eb3 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -16,10 +16,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSwitch = nextion_ns.class_("NextionSwitch", switch.Switch, cg.PollingComponent) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( switch.switch_schema(NextionSwitch) .extend(CONFIG_SWITCH_COMPONENT_SCHEMA) @@ -39,9 +35,8 @@ async def to_code(config): await setup_component_core_(var, config, ".val") -@automation.register_action( +automation.register_apply_action( "switch.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSwitch), @@ -52,19 +47,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/text_sensor/__init__.py b/esphome/components/nextion/text_sensor/__init__.py index 168a672497..b8fd0c8fda 100644 --- a/esphome/components/nextion/text_sensor/__init__.py +++ b/esphome/components/nextion/text_sensor/__init__.py @@ -13,10 +13,6 @@ NextionTextSensor = nextion_ns.class_( "NextionTextSensor", text_sensor.TextSensor, cg.PollingComponent ) -NextionPublishTextAction = nextion_ns.class_( - "NextionPublishTextAction", automation.Action -) - CONFIG_SCHEMA = ( text_sensor.text_sensor_schema(NextionTextSensor) .extend(CONFIG_TEXT_COMPONENT_SCHEMA) @@ -35,9 +31,8 @@ async def to_code(config): await setup_component_core_(var, config, ".txt") -@automation.register_action( +automation.register_apply_action( "text_sensor.nextion.publish", - NextionPublishTextAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionTextSensor), @@ -48,19 +43,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.std_string), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.const_char_ptr) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed..4f4347d89a 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -3,6 +3,7 @@ esphome: - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% + - display.nextion.set_brightness: !lambda 'return 0.5;' # Binary sensor publish action tests - binary_sensor.nextion.publish: From 4f95ffaa1f1628a8f433fd795672316058a61bc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:49:59 +0100 Subject: [PATCH 246/316] [network] Remove deprecated IPAddress::str() from the host build (#19528) --- esphome/components/network/ip_address.h | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 28f83cc4fa..3b0621d1f3 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -147,15 +147,6 @@ struct IPAddress { } return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL; } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (this->ip_addr_.type == IPADDR_TYPE_V6) { inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE); From c33ccc70430b1b76d0f5d2802eaed5936241b2f3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:50:54 +0100 Subject: [PATCH 247/316] [esp32][esp32_hosted] Bump recommended Arduino to 3.3.12 and platform to 55.03.312 and PlatformIO to 6.2.0 (#19417) --- esphome/components/esp32/__init__.py | 15 +++++++++------ esphome/components/esp32_hosted/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 6 ++++++ .../libretiny/scons_dont_inherit.py.script | 16 ++++++++++++++++ esphome/idf_component.yml | 2 +- platformio.ini | 7 ++++--- requirements.txt | 2 +- 7 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 esphome/components/libretiny/scons_dont_inherit.py.script diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e2e40f21bd..0d409b7857 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -424,6 +424,7 @@ ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( "Hash", "HTTPClient", "HTTPUpdate", + "HTTPUpdateServer", "Insights", "LittleFS", "Matter", @@ -931,14 +932,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 11), - "latest": cv.Version(3, 3, 11), - "dev": cv.Version(3, 3, 11), + "recommended": cv.Version(3, 3, 12), + "latest": cv.Version(3, 3, 12), + "dev": cv.Version(3, 3, 12), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 12): cv.Version(55, 3, 312), cv.Version(3, 3, 11): cv.Version(55, 3, 311), cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), @@ -963,6 +965,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 12): cv.Version(5, 5, 5), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -998,7 +1001,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 311), + cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), @@ -1019,8 +1022,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 311), - "latest": cv.Version(55, 3, 311), + "recommended": cv.Version(55, 3, 312), + "latest": cv.Version(55, 3, 312), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6943efc3cb..ad01e130eb 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -313,7 +313,7 @@ async def to_code(config: ConfigType) -> None: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 50dc787799..8cd7f3e7b6 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,6 +513,8 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # Must run before the platform's builder scripts are loaded; see the script. + cg.add_platformio_option("extra_scripts", ["pre:scons_dont_inherit.py"]) cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). # On other families, patch_linker.py routes .sram.text into the right @@ -618,4 +620,8 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_file_if_changed( + script_dir / "scons_dont_inherit.py.script", + CORE.relative_build_path("scons_dont_inherit.py"), + ) copy_ccache_script() diff --git a/esphome/components/libretiny/scons_dont_inherit.py.script b/esphome/components/libretiny/scons_dont_inherit.py.script new file mode 100644 index 0000000000..23da6ff396 --- /dev/null +++ b/esphome/components/libretiny/scons_dont_inherit.py.script @@ -0,0 +1,16 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import builtins +import functools +import sys + +# SCons 4.11 compiles SConscript files without dont_inherit=True, so its own +# `from __future__ import annotations` leaks into every builder script and +# turns their annotations into strings. That breaks LibreTiny's Library +# dataclass, whose InitVar is then no longer recognised. Runs before the +# platform's builder scripts are loaded. The module comes from sys.modules +# because SCons.Script also exports a function named SConscript. +sconscript = sys.modules["SCons.Script.SConscript"] +if sconscript.__dict__.get("compile") is None: + sconscript.compile = functools.partial(builtins.compile, dont_inherit=True) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index d12a27221b..65d56495af 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.12 + version: 2.12.13 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/platformio.ini b/platformio.ini index 37504384cb..102c7973b9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,9 +141,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -178,7 +178,7 @@ extra_scripts = ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz @@ -257,6 +257,7 @@ build_flags = build_unflags = ${common.build_unflags} build_src_flags = -include Arduino.h +extra_scripts = pre:esphome/components/libretiny/scons_dont_inherit.py.script ; This is the common settings for the nRF52 using Zephyr. [common:nrf52-zephyr] diff --git a/requirements.txt b/requirements.txt index fd69353345..eacd7e0970 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ colorama==0.4.6 tzlocal==5.4.4 # from time tzdata>=2026.4 # from time pyserial==3.5 -platformio==6.1.19 +platformio==6.2.0 esptool==5.4.0 click==8.3.3 aioesphomeapi==46.5.0 From 4a7fe83a1793084cd4607354d5bd5ba0c3659ac7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:51:20 +0100 Subject: [PATCH 248/316] [speaker] Use register_apply_action for the speaker actions (#19535) --- esphome/components/speaker/__init__.py | 55 ++++--------------- esphome/components/speaker/automation.h | 35 ------------ .../speaker/media_player/__init__.py | 36 +++++------- .../speaker/media_player/automation.h | 25 --------- .../speaker/common-media_player.yaml | 5 ++ tests/components/speaker/common.yaml | 3 + 6 files changed, 34 insertions(+), 125 deletions(-) delete mode 100644 esphome/components/speaker/media_player/automation.h diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 98b5abe58c..e62e4ccfd3 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -18,21 +18,6 @@ Speaker = speaker_ns.class_("Speaker") PlayAction = speaker_ns.class_( "PlayAction", automation.Action, cg.Parented.template(Speaker) ) -StopAction = speaker_ns.class_( - "StopAction", automation.Action, cg.Parented.template(Speaker) -) -FinishAction = speaker_ns.class_( - "FinishAction", automation.Action, cg.Parented.template(Speaker) -) -VolumeSetAction = speaker_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOnAction = speaker_ns.class_( - "MuteOnAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOffAction = speaker_ns.class_( - "MuteOffAction", automation.Action, cg.Parented.template(Speaker) -) IsPlayingCondition = speaker_ns.class_("IsPlayingCondition", automation.Condition) @@ -96,12 +81,15 @@ async def speaker_play_action(config, action_id, template_arg, args): return var -automation.register_action( - "speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) -automation.register_action( - "speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) +for _name, _call in ( + ("speaker.stop", "stop()"), + ("speaker.finish", "finish()"), + ("speaker.mute_on", "set_mute_state(true)"), + ("speaker.mute_off", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, SPEAKER_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) automation.register_condition( "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA @@ -112,9 +100,8 @@ automation.register_condition( )(speaker_action) -@automation.register_action( +automation.register_apply_action( "speaker.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Speaker), @@ -122,28 +109,8 @@ automation.register_condition( }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def speaker_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var - - -@automation.register_action( - "speaker.mute_off", - MuteOffAction, - SPEAKER_AUTOMATION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -) -async def speaker_mute_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 443588a04c..50097ab59a 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -38,41 +38,6 @@ template class PlayAction final : public Action, public P } data_; }; -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(true); } - - protected: - Speaker *speaker_; -}; - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(false); } - - protected: - Speaker *speaker_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class FinishAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish(); } -}; - template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index e1808889f4..78892bff22 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -22,7 +22,6 @@ from esphome.const import ( CONF_BUFFER_SIZE, CONF_FILES, CONF_FORMAT, - CONF_ID, CONF_NUM_CHANNELS, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -63,11 +62,6 @@ AUDIO_PIPELINE_TYPE_ENUM = { "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, } -PlayOnDeviceMediaAction = speaker_ns.class_( - "PlayOnDeviceMediaAction", - automation.Action, - cg.Parented.template(SpeakerMediaPlayer), -) StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) ) @@ -239,9 +233,8 @@ async def to_code(config): audio_file.generate_audio_file_code(file_config) -@automation.register_action( +automation.register_apply_action( "media_player.speaker.play_on_device_media_file", - PlayOnDeviceMediaAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SpeakerMediaPlayer), @@ -251,17 +244,18 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {}, {})", + ( + # The file is a use_id of a global AudioFile pointer; global-scope qualified + # so a trigger arg with the same name cannot shadow it. + ( + CONF_MEDIA_FILE, + audio.AudioFile.operator("ptr"), + lambda _, value: f"::{value}", + ), + (CONF_ANNOUNCEMENT, cg.bool_), + (CONF_ENQUEUE, cg.bool_), + ), + ), ) -async def play_on_device_media_media_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_file = await cg.get_variable(config[CONF_MEDIA_FILE]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - enqueue = await cg.templatable(config[CONF_ENQUEUE], args, cg.bool_) - - template_ = await cg.templatable(media_file, args, audio.AudioFile.operator("ptr")) - cg.add(var.set_audio_file(template_)) - cg.add(var.set_announcement(announcement)) - cg.add(var.set_enqueue(enqueue)) - return var diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h deleted file mode 100644 index f9e2127993..0000000000 --- a/esphome/components/speaker/media_player/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "speaker_media_player.h" - -#ifdef USE_ESP32 - -#include "esphome/components/audio/audio.h" -#include "esphome/core/automation.h" - -namespace esphome::speaker { - -template -class PlayOnDeviceMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) - TEMPLATABLE_VALUE(bool, announcement) - TEMPLATABLE_VALUE(bool, enqueue) - void play(const Ts &...x) override { - this->parent_->play_file(this->audio_file_.value(x...), this->announcement_.value(x...), - this->enqueue_.value(x...)); - } -}; - -} // namespace esphome::speaker - -#endif diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index 3b2212a0ca..7aef904a92 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -30,3 +30,8 @@ script: id: speaker_media_player_id media_file: speaker_test_audio announcement: true + - media_player.speaker.play_on_device_media_file: + id: speaker_media_player_id + media_file: speaker_test_audio + announcement: !lambda return id(speaker_my_number).state > 50; + enqueue: !lambda return id(speaker_my_number).state < 50; diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f..e5c9f36650 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -25,6 +25,9 @@ esphome: - speaker.volume_set: id: speaker_id volume: 0.9 + - speaker.volume_set: + id: speaker_id + volume: !lambda return id(speaker_my_number).state / 100.0f; - if: condition: speaker.is_playing: From fa7ccc934e0de9827f932b442bc059d4e4c670d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:52:00 +0100 Subject: [PATCH 249/316] [pulse_meter] Use register_apply_action for pulse_meter.set_total_pulses (#19533) --- esphome/components/pulse_meter/automation.h | 21 --------------------- esphome/components/pulse_meter/sensor.py | 21 +++------------------ tests/components/pulse_meter/common.yaml | 3 +++ 3 files changed, 6 insertions(+), 39 deletions(-) delete mode 100644 esphome/components/pulse_meter/automation.h diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h deleted file mode 100644 index 885922a22a..0000000000 --- a/esphome/components/pulse_meter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_meter/pulse_meter_sensor.h" - -namespace esphome::pulse_meter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_meter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseMeterSensor *pulse_meter_; -}; - -} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 9bda891efc..2df1ba96e9 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -19,8 +19,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID, TimePeriodMicroseconds -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriodMicroseconds from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -38,8 +37,6 @@ FILTER_MODES = { "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, } -SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) - def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) @@ -100,25 +97,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_meter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseMeterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/tests/components/pulse_meter/common.yaml b/tests/components/pulse_meter/common.yaml index a83ec478bb..8721f2e375 100644 --- a/tests/components/pulse_meter/common.yaml +++ b/tests/components/pulse_meter/common.yaml @@ -9,5 +9,8 @@ sensor: - pulse_meter.set_total_pulses: id: pulse_meter_sensor value: 12345 + - pulse_meter.set_total_pulses: + id: pulse_meter_sensor + value: !lambda return (uint32_t) x; total: name: Pulse Meter Total From 9d87fcd3b7c9d0ca68bb5771ebd97adb26b49ade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:52:05 +0100 Subject: [PATCH 250/316] [esp8266] Add the native build spec (#18559) --- esphome/build_gen/arduino8266.py | 773 +++++++++++ .../unit_tests/build_gen/test_arduino8266.py | 1210 +++++++++++++++++ 2 files changed, 1983 insertions(+) create mode 100644 esphome/build_gen/arduino8266.py create mode 100644 tests/unit_tests/build_gen/test_arduino8266.py diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 0000000000..7d842308f6 --- /dev/null +++ b/esphome/build_gen/arduino8266.py @@ -0,0 +1,773 @@ +"""Build specification for the native ESP8266 Arduino toolchain. + +Transliterates the PlatformIO build spec for the Arduino ESP8266 framework +(``framework-arduinoespressif8266/tools/platformio-build.py`` plus +``platform-espressif8266/builder/main.py``): the flag sets, defines, and +linker-script generation deliberately match what PlatformIO produces so the +binaries stay near-identical between the two toolchains. The ninja emission +(``write_project``) builds on these pieces. + +The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK +version, MMU layout, exceptions, waveform phase) keep working: they are read +from the build flags with the same precedence as the PlatformIO builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import logging +import os +from pathlib import Path +import re +import shlex +import subprocess +from typing import TYPE_CHECKING, NamedTuple + +from esphome.arduino8266.framework import toolchain_tool +from esphome.build_helpers.ninja import shell_token as _shell_token +from esphome.components.esp8266 import build_surgery +from esphome.core import CORE, EsphomeError +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + +_LOGGER = logging.getLogger(__name__) + +# Values that land unquoted on generated command lines are shape-checked +# against these before use. re.ASCII: a Unicode digit or word character +# (Arabic-Indic numerals) would pass \d/\w and defeat the named error +_MMU_VALUE_RE = re.compile(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*", re.ASCII) +_MMU_HEX_VALUE_RE = re.compile(r"0[xX][0-9a-fA-F]+[uUlL]*", re.ASCII) +# Only these land in the preprocessed script's ``len =`` fields, which +# build_surgery's segment parser reads back as hex; the other MMU_* macros +# (MMU_EXTERNAL_HEAP=128) are consumed by mmu_iram.h and may be decimal +_MMU_SEGMENT_SIZE_NAMES = ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE") +_BOARD_NAME_RE = re.compile(r"[\w.-]+", re.ASCII) +_F_CPU_RE = re.compile(r"\d+L?", re.ASCII) +_FLASH_LD_NAME_RE = re.compile(r"[\w.-]+\.ld", re.ASCII) + +# Every supported board ships this clock; board_build.f_cpu overrides +_DEFAULT_F_CPU = "80000000L" + +# The SDK linker-script template and the preprocessed copy the build links +# against; the cache stamp and stderr sidecars derive from the output name +_COMMON_LD_HEADER = "eagle.app.v6.common.ld.h" +_COMMON_LD_NAME = "local.eagle.app.v6.common.ld" +# Testing mode shadows the SDK flash ld with a patched copy under this name +_TESTING_LD_PREFIX = "testing_" + +# The recovery hint for a half-extracted or damaged framework cache +_CLEAN_HINT = "run 'esphome clean-all' and retry" + + +def _sdk_ld_dir(framework: Path) -> Path: + return framework / "tools" / "sdk" / "ld" + + +def _apply_surgery(fn, *args: object) -> str: + """Run one build_surgery edit, naming a failed anchor instead of a + traceback (the surgery module raises bare RuntimeError so its + ``.py.script`` twins stay importable without esphome).""" + try: + return fn(*args) + except RuntimeError as err: + raise EsphomeError(str(err)) from err + + +# From platformio-build.py. Knob suffix -> SDK define; the first entry is +# the default (dicts preserve insertion order). With multiple SDK knobs set +# (a pathological config) ties break by table order, since upstream's +# tie-break depends on define order and is not reproducible here. +_NONOSDK_VERSIONS = { + "SDK22x_190703": "NONOSDK22x_190703", + "SDK221": "NONOSDK221", + "SDK22x_190313": "NONOSDK22x_190313", + "SDK22x_191024": "NONOSDK22x_191024", + "SDK22x_191105": "NONOSDK22x_191105", + "SDK22x_191122": "NONOSDK22x_191122", + "SDK305": "NONOSDK305", +} + + +class _LwipVariant(NamedTuple): + """One lwIP build variant: the defines and the prebuilt library that + was compiled with them.""" + + tcp_mss: int + features: int + ipv6: int + lib: str + + +# Knob define -> variant; first match wins, in insertion order (as in +# platformio-build.py) +_LWIP_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY": _LwipVariant( + 536, 1, 1, "lwip6-536-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 1, "lwip6-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 0, "lwip2-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH": _LwipVariant( + 536, 0, 0, "lwip2-536" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH": _LwipVariant( + 1460, 0, 0, "lwip2-1460" + ), +} +# The default is PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY's variant: upstream +# has no branch for that spelling (it is the else), so any listed knob wins +# over it -- sntp emits LOW_MEMORY while esp8266 always emits +# HIGHER_BANDWIDTH_LOW_FLASH, and the latter must win as under PlatformIO +_LWIP_DEFAULT = _LwipVariant(536, 1, 0, "lwip2-536-feat") + +# Knob define -> MMU_* defines; first match wins, in insertion order (as +# in platformio-build.py) +_MMU_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_HEAP", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=128", + ), + # Upstream really does cap the 1024K option's heap knob at 256 + # (platformio-build.py's MMU_EXTERNAL_1024K branch); transliterated + # verbatim + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=256", + ), +} +# From platformio-build.py: the invariant framework defines every TU gets +# (ARDUINO=10805 encodes the IDE compatibility level); the board, flash-mode, +# knob, and MMU defines are composed around them in _defines_flags, in +# upstream's order. +_FRAMEWORK_DEFINES = ("__ets__", "ICACHE_FLASH", "_GNU_SOURCE", "ARDUINO=10805") +_ARCH_DEFINES = ("ESP8266", "ARDUINO_ARCH_ESP8266") + +# Upstream reads these from the board manifest (build.mmu_iram_size etc.); +# no supported board sets them, so the platformio-build.py defaults are +# hardcoded here rather than drift +_MMU_DEFAULT = ("MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000") + +# Upstream's CXXFLAGS (-fno-rtti, the -std level, -f(no-)exceptions) and the +# trailing stdc++/m/c/gcc system libs are composed at emission +# (write_project) from CORE.cpp_standard and _BuildConfig.exceptions. +_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"] +_CFLAGS = [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", +] +_CCFLAGS = [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", +] +# Upstream's -u _scanf_float is deliberately absent: it is re-added from +# KEY_SCANF_FLOAT at emission (the remove_float_scanf extra script's job). +_LINKFLAGS = [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", +] +_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"] +_SYSTEM_LIBS_POST_LWIP = [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", +] + + +@dataclass +class _BuildConfig: + """Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines).""" + + nonosdk: str + lwip_lib: str + exceptions: bool + vtables: str + fp_in_irom: bool + knob_defines: list[str] + mmu_defines: list[str] + + +def _lexed_build_flags() -> list[str]: + """Shell-lex ``CORE.build_flags`` as PlatformIO's ``ParseFlags`` does, + sorted so duplicate defines resolve deterministically. + + Lex once per build; consumers share the tokens. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + return lex_build_flags(sorted(CORE.build_flags), "esphome") + + +def _flag_defines(unflags: set[str], tokens: list[str]) -> dict[str, str]: + """Map define name -> full ``NAME[=VALUE]`` for every -D build flag. + + ``tokens`` comes from one ``_lexed_build_flags()`` call shared with + ``_project_flags``, which already warned about and dropped any bare "-D". + """ + defines: dict[str, str] = {} + for tok in tokens: + # An unflagged knob must not drive lwIP/SDK/MMU selection while + # being absent from the compile line + if tok in unflags: + continue + if tok.startswith("-D"): + body = tok[2:] + defines[body.split("=", 1)[0]] = body + return defines + + +def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: + nonosdk = next( + ( + define + for name, define in _NONOSDK_VERSIONS.items() + if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines + ), + next(iter(_NONOSDK_VERSIONS.values())), + ) + # Same compile-line/linked-artifact split as the lwIP knobs below: a + # raw NONOSDK* would define a second SDK macro while the link still + # resolves against the knob's libraries + if raw_sdk := sorted(n for n in defines if n.startswith("NONOSDK")): + raise EsphomeError( + f"{', '.join(raw_sdk)} are set by the " + "PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK* knobs; drop the raw " + "build flags" + ) + + lwip = next( + (variant for knob, variant in _LWIP_VARIANTS.items() if knob in defines), + _LWIP_DEFAULT, + ) + + # The lwIP triple selects a prebuilt library; a raw override would win + # the compile line (user tokens come last here) while the link still + # pulls the library built for the knob's values + if owned := sorted( + n for n in ("TCP_MSS", "LWIP_FEATURES", "LWIP_IPV6") if n in defines + ): + raise EsphomeError( + f"{', '.join(owned)} are set by the PIO_FRAMEWORK_ARDUINO_LWIP2_* " + "knobs; drop the raw build flags" + ) + knob_defines = [ + f"{nonosdk}=1", + f"TCP_MSS={lwip.tcp_mss}", + f"LWIP_FEATURES={lwip.features}", + f"LWIP_IPV6={lwip.ipv6}", + ] + if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines: + knob_defines.append("WAVEFORM_LOCKED_PHASE=1") + + # Sorted so the pick is deterministic: the dict is built from a set of + # build flags, whose iteration order varies between processes. + vtables_knobs = sorted(name for name in defines if name.startswith("VTABLES_IN_")) + known_vtables = {"VTABLES_IN_FLASH", "VTABLES_IN_DRAM", "VTABLES_IN_IRAM"} + # A typo'd or conflicting knob would otherwise fail obscurely in the + # SDK header's #error + if unknown := [k for k in vtables_knobs if k not in known_vtables]: + raise EsphomeError(f"Unknown VTABLES_IN_* define(s): {', '.join(unknown)}") + # A body (e.g. VTABLES_IN_FLASH=0) would split the compile line from the + # linker script, which always defines the bare name + if valued := [defines[k] for k in vtables_knobs if defines[k] not in (k, f"{k}=1")]: + raise EsphomeError(f"VTABLES_IN_* defines take no value: {', '.join(valued)}") + if len(vtables_knobs) > 1: + raise EsphomeError( + f"Conflicting VTABLES_IN_* defines: {', '.join(vtables_knobs)}" + ) + vtables = vtables_knobs[0] if vtables_knobs else "VTABLES_IN_FLASH" + + mmu_knob = next((knob for knob in _MMU_VARIANTS if knob in defines), None) + if mmu_knob is not None: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Same compile-line/linker-script split as the no-knob case below + fix = ( + f"drop {mmu_knob} to use the custom sizes" + if "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines + else "drop the raw MMU_* build flags or use " + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + raise EsphomeError(f"{', '.join(raw)} conflict with {mmu_knob}; {fix}") + mmu = list(_MMU_VARIANTS[mmu_knob]) + elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines: + if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines: + raise EsphomeError( + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and " + "MMU_ICACHE_SIZE build flags" + ) + for name in _MMU_SEGMENT_SIZE_NAMES: + # A bare -Dname would preprocess to len = 1 and fail far away + if "=" not in defines[name]: + raise EsphomeError( + f"{name} must be a hex literal (e.g. 0x8000), got (no value)" + ) + for name, body in defines.items(): + if not name.startswith("MMU_") or "=" not in body: + # Valueless flags (MMU_IRAM_HEAP) are legitimate switches + continue + # Every valued MMU_* reaches the linker-script preprocessor; a + # bare or non-numeric value would corrupt it and fail far away + # in ld. The two segment sizes must additionally be hex: + # build_surgery's segment parser cannot read decimal back. + value = body.partition("=")[2] + rule = ( + _MMU_HEX_VALUE_RE if name in _MMU_SEGMENT_SIZE_NAMES else _MMU_VALUE_RE + ) + if not rule.fullmatch(value): + shape = ( + "a hex literal (e.g. 0x8000)" + if name in _MMU_SEGMENT_SIZE_NAMES + else "a numeric literal" + ) + raise EsphomeError( + f"{name} must be {shape}, got {value or '(no value)'}" + ) + # Sorted so build.ninja and the linker-script stamp stay + # byte-stable across runs (the flag set has no deterministic + # iteration order). + mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_")) + else: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Unlike PlatformIO (whose defaults win the compile line), user + # MMU_* here would win the compile but not the linker script; + # refuse them all, like the knob branch above. + raise EsphomeError( + f"Raw {', '.join(raw)} build flags require " + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + mmu = list(_MMU_DEFAULT) + + return _BuildConfig( + nonosdk=nonosdk, + lwip_lib=lwip.lib, + exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines, + vtables=vtables, + fp_in_irom="FP_IN_IROM" in defines, + knob_defines=knob_defines, + mmu_defines=mmu, + ) + + +def _pio_option(key: str, default: str) -> str: + """A platformio_options value the native build honors (str-normalized). + + core/config.py routes these into ``CORE.platformio_options`` under the + arduino toolchain and already collapses a repeated option to its last + value (like a later platformio.ini line), so a scalar always arrives. + """ + value = CORE.platformio_options.get(key) + if value is None: + return default + value = str(value).strip() + if not value: + raise EsphomeError(f"platformio_options {key} is empty") + return value + + +def _defines_flags( + config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...] +) -> list[str]: + r"""The framework/board -D tokens for the compile line. + + The returned tokens already carry shell-level escaping (the board + defines embed ``\"``), so they must be emitted unquoted; wrapping + them in ``_shell_token`` would deliver literal backslashes to gcc. + ``flash_mode`` also lands unquoted: callers pass it pre-validated + against ``BUILD_FLASH_MODES`` (cv.one_of at config time, the + ``_FLASH_MODES`` check at the emission half's read site). + """ + if not _BOARD_NAME_RE.fullmatch(board): + # The name lands unquoted in two -D bodies; reject it by name + # instead of corrupting the compile line + raise EsphomeError(f"Invalid board name {board!r}") + # Every supported board ships 80 MHz; board_build.f_cpu overrides + f_cpu = _pio_option("board_build.f_cpu", _DEFAULT_F_CPU) + if not _F_CPU_RE.fullmatch(f_cpu): + # The value lands unquoted on the compile line; reject by name + # instead of corrupting it + raise EsphomeError(f"Invalid board_build.f_cpu value {f_cpu!r}") + return [ + f"-D{d}" + for d in ( + f"F_CPU={f_cpu}", + *_FRAMEWORK_DEFINES, + f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"', + f'ARDUINO_BOARD_ID=\\"{board}\\"', + f"FLASHMODE_{flash_mode.upper()}", + "LWIP_OPEN_SRC", + *config.knob_defines, + config.vtables, + # User-supplied bodies re-quote like every other user token + # (a no-op for real MMU values) + *(_shell_token(d) for d in config.mmu_defines), + *_ARCH_DEFINES, + *board_defines, + ) + ] + + +def _unflag_tokens() -> set[str]: + """``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches.""" + # Lexed like _lexed_build_flags reads build_flags, so "-D FOO" removes + # -DFOO in both spellings (PlatformIO's ProcessUnFlags parses the same + # way) and no bare half can collaterally drop an unrelated token + return set(lex_build_flags(list(CORE.build_unflags), "esphome build_unflags")) + + +def _project_flags( + unflags: set[str], tokens: list[str] +) -> tuple[list[str], list[str], list[Path], list[str]]: + """Split the ESPHome build flags into compile, linker, -L, and -l lists. + + Plain-form linker flags (``_PLAIN_LINKER_FLAGS``/``_PLAIN_LINKER_PREFIXES``) + raise: they would be inert on the ``-c`` compile line. + ``compile_flags``/``link_flags`` come back shell-quoted; + ``lib_dirs``/``libs`` are raw, quote at emission. + """ + compile_flags: list[str] = [] + link_flags: list[str] = [] + lib_dirs: list[Path] = [] + libs: list[str] = [] + for tok in tokens: + if tok in unflags: + continue + # _lexed_build_flags warned about and dropped any bare -I/-D/-L/-l + if tok.startswith("-Wl,"): + link_flags.append(_shell_token(tok)) + elif tok.startswith("-L"): + lib_dirs.append(Path(tok[2:])) + elif tok.startswith("-l"): + libs.append(tok[2:]) + else: + if tok.startswith(_PLAIN_DRIVER_LINK_PREFIXES): + # Driver options with no -Wl, spelling; ld would reject them + raise EsphomeError( + f"Link flag {tok} in build_flags is not supported by the " + "native toolchain" + ) + if tok in _PLAIN_LINKER_FLAGS or tok.startswith(_PLAIN_LINKER_PREFIXES): + raise EsphomeError( + f"Linker flag {tok} in build_flags is not routed to the " + "link line; use the -Wl, form" + ) + if tok.startswith("-") and not tok.startswith(_COMPILE_FLAG_PREFIXES): + # The linker deny lists are not exhaustive; an unlisted + # link-only spelling would be inert on the -c compile line, + # so at least surface the odd shape + _LOGGER.warning( + "Build flag %s is not a recognized compile-flag shape; " + "it is passed to the compile line only", + tok, + ) + compile_flags.append(_shell_token(tok)) + return compile_flags, link_flags, lib_dirs, libs + + +# Recognized compile-flag shapes: the allow-list feeding the fall-through +# warning in _project_flags (an unlisted link-only spelling still reaches +# the compile line, but not silently) +_COMPILE_FLAG_PREFIXES = ( + "-D", + "-I", + "-U", + "-W", + "-f", + "-m", + "-O", + "-g", + "-std=", + "-include", +) +# Plain-form linker flags rejected by _project_flags: inert on a -c compile +# line, so the firmware would silently lack the requested link behavior. +# Best-effort, not exhaustive; see _COMPILE_FLAG_PREFIXES above. +_PLAIN_LINKER_FLAGS = ( + "-u", + "-e", + "-s", + "-static", + "-nostartfiles", + "-nodefaultlibs", + "-nostdlib", + "-rdynamic", +) +_PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") +# Driver options, not ld options: -Wl, has no equivalent for these +_PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") + + +def _stat_sig(path: Path) -> str: + """Size and mtime cache-stamp signature for one input file. + + Absent stays deterministic ("missing": the spawn names it); unreadable + forces a cache miss every run rather than pinning the stamp to a + constant that can never notice a later edit. + """ + try: + st = path.stat() + return f"{st.st_size}:{st.st_mtime_ns}" + except FileNotFoundError: + return "missing" + except OSError as err: + _LOGGER.warning( + "Could not stat %s (%s); regenerating the linker script every " + "build. Run 'esphome clean-all' to reinstall the framework.", + path, + err, + ) + return f"unreadable:{os.urandom(8).hex()}" + + +def _write_note(path: Path, text: str, *, warn: bool = False) -> bool: + """Best-effort bookkeeping write; a failure never fails the build. + + ``warn`` marks notes whose loss drops a diagnostic on later cached + builds; a lost stamp only costs a cache miss and stays at debug. + Returns whether the write persisted, so a lost warn note can veto + the cache stamp and keep the diagnostic re-derivable. + """ + try: + path.write_text(text, encoding="utf-8") + except OSError as err: + log = _LOGGER.warning if warn else _LOGGER.debug + log("Could not write %s: %s", path, err) + return False + return True + + +def generate_ld_scripts( + paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str +) -> None: + """Generate the common linker script (and testing-mode flash ld copy). + + Runs the same preprocessor invocation as the PlatformIO builder over + ``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi + rate-table DRAM relocation, and enlarged memory segments in testing mode. + """ + if not _FLASH_LD_NAME_RE.fullmatch(flash_ld_name): + # Joined under the SDK and build ld dirs; never a path or traversal + raise EsphomeError(f"Invalid flash linker script name {flash_ld_name!r}") + framework = paths.framework + gcc = toolchain_tool(paths.toolchain, "gcc") + ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld") + mkdir_p(ld_dir) + + cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"] + cmd += [f"-D{d}" for d in config.mmu_defines] + if config.fp_in_irom: + cmd.append("-DFP_IN_IROM") + header = _sdk_ld_dir(framework) / _COMMON_LD_HEADER + cmd += [str(header), "-o", "-"] + + # The inputs are the command line (defines + framework version, which is + # baked into the paths) plus testing mode; skip the preprocessor spawn on + # incremental builds when nothing changed. + output = ld_dir / _COMMON_LD_NAME + stamp = ld_dir / f".{_COMMON_LD_NAME}.stamp" + # Stamp includes the header/gcc stat (catches in-place re-extraction) + # and the surgery fingerprint (a build_surgery edit invalidates old + # build dirs) + stamp_content = ( + # shlex.join: a spaced path stays one quoted element, so two + # different cmd lists can never collide to the same stamp string + shlex.join(cmd) + + f" testing={CORE.testing_mode}" + + f" header={_stat_sig(header)}" + + f" gcc={_stat_sig(gcc)}" + + f" {build_surgery.surgery_fingerprint()}" + ) + + stderr_note = ld_dir / f".{_COMMON_LD_NAME}.stderr" + + def _note_digest() -> str: + # The note is an output like the script itself; folding its state + # into the stamp makes an externally removed or edited note a cache + # miss that re-runs -E and re-derives the diagnostic + if not stderr_note.is_file(): + return "none" + return hashlib.sha256(stderr_note.read_bytes()).hexdigest() + + def _cached_ld_is_valid() -> bool: + # Any damaged cache regenerates; never abort the build over it. The + # stamp records the sha256 of the content written, so an externally + # edited script regenerates too. + try: + if not (output.is_file() and stamp.is_file()): + return False + rest, sep, digest = stamp.read_text(encoding="utf-8").rpartition( + " content=" + ) + inputs, note_sep, note_digest = rest.rpartition(" note=") + return ( + bool(sep) + and bool(note_sep) + and inputs == stamp_content + and note_digest == _note_digest() + and hashlib.sha256(output.read_bytes()).hexdigest() == digest + ) + except (OSError, UnicodeDecodeError): + return False + + if not _cached_ld_is_valid(): + try: + result = subprocess.run( + cmd, + capture_output=True, + check=False, + close_fds=False, + ) + except OSError as err: + # A half-extracted or half-deleted toolchain cache reaches here + raise EsphomeError(f"Could not run {gcc}: {err}; {_CLEAN_HINT}") from err + # Localized gcc diagnostics on a non-UTF-8 console must degrade, + # not UnicodeDecodeError the build; the script itself (below) is + # decoded strictly instead, so a mangled byte can never be cached + stderr_text = result.stderr.decode("utf-8", errors="replace") + if result.returncode != 0: + raise EsphomeError(f"Generating the linker script failed:\n{stderr_text}") + note_persisted = True + if stderr_text.strip(): + # Preprocessor warnings on the success path must reach the user + # on this and every later cached build (see the re-emit below) + _LOGGER.warning("Linker-script preprocessor: %s", stderr_text.strip()) + note_persisted = _write_note(stderr_note, stderr_text.strip(), warn=True) + else: + try: + stderr_note.unlink(missing_ok=True) + except OSError as err: + # A kept stale note would re-emit an obsolete diagnostic on + # every cache hit; skip the stamp so -E re-derives the truth + _LOGGER.warning( + "Could not remove %s (%s); the linker script will " + "regenerate every build until it is removable; %s", + stderr_note, + err, + _CLEAN_HINT, + ) + note_persisted = False + try: + stdout_text = result.stdout.decode("utf-8") + except UnicodeDecodeError as err: + # -CC keeps header comments verbatim; a non-UTF-8 byte replaced + # with U+FFFD would be cached as valid for the build dir's life + raise EsphomeError( + f"Preprocessed linker script from {header} is not UTF-8: " + f"{err}; {_CLEAN_HINT}" + ) from err + if "SECTIONS" not in stdout_text: + # A degenerate zero-exit run must not be stamped as a good cache + raise EsphomeError( + f"Generated linker script is missing its SECTIONS block; {_CLEAN_HINT}" + ) + content = _apply_surgery(build_surgery.relocate_ratetable, stdout_text) + if CORE.testing_mode: + content = _apply_surgery( + build_surgery.apply_testing_memory_patches, content, ("iram1_0_seg",) + ) + write_file_if_changed(output, content) + if note_persisted: + # An unstamped cache re-runs -E next build, re-deriving the + # diagnostic the lost note would have re-emitted + _write_note( + stamp, + f"{stamp_content} note={_note_digest()} " + f"content={hashlib.sha256(content.encode('utf-8')).hexdigest()}", + ) + elif stderr_note.is_file(): + # Re-emit cached preprocessor warnings on cache hits + try: + _LOGGER.warning( + "Linker-script preprocessor: %s", + stderr_note.read_text(encoding="utf-8"), + ) + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "A cached linker-script preprocessor diagnostic exists at %s " + "but could not be read: %s", + stderr_note, + err, + ) + + if CORE.testing_mode: + _generate_testing_flash_ld(framework, ld_dir, flash_ld_name) + + +def _generate_testing_flash_ld( + framework: Path, ld_dir: Path, flash_ld_name: str +) -> None: + """A patched copy of the flash ld in the build dir; resolved through the + same -L path as the SDK original it shadows.""" + flash_ld = _sdk_ld_dir(framework) / flash_ld_name + try: + flash_ld_text = flash_ld.read_text(encoding="utf-8") + except OSError as err: + # Same half-extracted-cache hazard as the preprocessor spawn + raise EsphomeError(f"Could not read {flash_ld}: {err}; {_CLEAN_HINT}") from err + patched_flash_ld = _apply_surgery( + build_surgery.apply_testing_memory_patches, + flash_ld_text, + ("dram0_0_seg", "irom0_0_seg"), + ) + write_file_if_changed( + ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld + ) diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py new file mode 100644 index 0000000000..21a81804ab --- /dev/null +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -0,0 +1,1210 @@ +"""Drift tests for the native ESP8266 Arduino build spec. + +These pin the ESPHome side of the transliteration (the knob-define +precedence, the define/flag sets, and the linker-script generation) against +literals audited from the PlatformIO builder +(framework-arduinoespressif8266/tools/platformio-build.py and +platform-espressif8266/builder/main.py). They catch an accidental edit on +this side; an upstream change in a new framework release is caught by the +A/B byte-identical build check on a version bump, not by these tests. +""" + +from __future__ import annotations + +from collections.abc import Generator +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.arduino8266.framework import InstalledPaths, toolchain_tool +from esphome.build_gen import arduino8266 +from esphome.build_gen.arduino8266 import ( + _defines_flags, + _flag_defines, + _resolve_build_config, +) +from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD +from esphome.components.esp8266.build_surgery import RATETABLE_RULE +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_MODE, + KEY_SCANF_FLOAT, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> Generator[None]: + CORE.name = "test8266" + CORE.build_path = tmp_path + CORE.testing_mode = False + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} + CORE.data[KEY_ESP8266] = { + KEY_BOARD: "nodemcuv2", + KEY_FLASH_MODE: "dout", + KEY_SCANF_FLOAT: False, + } + yield + # CORE.reset() (the suite-wide autouse fixture) does not clear this flag + CORE.testing_mode = False + + +def _set_flags(*flags: str) -> None: + CORE.build_flags = set(flags) + + +def _resolve(*flags: str) -> arduino8266._BuildConfig: + """Set the build flags and resolve the knob config in one step.""" + _set_flags(*flags) + return _resolve_current() + + +def _defines() -> dict[str, str]: + """The -D map for the current build flags.""" + return _flag_defines(set(), arduino8266._lexed_build_flags()) + + +def _resolve_current() -> arduino8266._BuildConfig: + """Resolve whatever flags are already set (must not clear them).""" + return _resolve_build_config(_defines()) + + +def _split_flags() -> tuple[list[str], list[str], list[Path], list[str]]: + """Classify the current build flags the way write_project does.""" + return arduino8266._project_flags( + arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() + ) + + +def _ok_result( + stdout: str | bytes | None = None, stderr: str | bytes = "" +) -> MagicMock: + """A successful preprocessor spawn (defaults to the common ld output). + + Streams are bytes, as the un-decoded subprocess.run delivers them. + """ + stdout = _COMMON_LD_H_OUTPUT if stdout is None else stdout + return MagicMock( + returncode=0, + stdout=stdout.encode() if isinstance(stdout, str) else stdout, + stderr=stderr.encode() if isinstance(stderr, str) else stderr, + ) + + +def test_build_config_defaults() -> None: + + config = _resolve() + assert config.nonosdk == "NONOSDK22x_190703" + assert config.lwip_lib == "lwip2-536-feat" + assert not config.exceptions + assert config.vtables == "VTABLES_IN_FLASH" + assert config.knob_defines == [ + "NONOSDK22x_190703=1", + "TCP_MSS=536", + "LWIP_FEATURES=1", + "LWIP_IPV6=0", + ] + assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"] + + +def test_build_config_esphome_lwip_knob() -> None: + """The lwIP variant ESPHome selects maps to the same defines and library + as the PlatformIO builder.""" + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert config.lwip_lib == "lwip2-1460" + assert "TCP_MSS=1460" in config.knob_defines + assert "LWIP_FEATURES=0" in config.knob_defines + assert "LWIP_IPV6=0" in config.knob_defines + + +def test_build_config_knobs() -> None: + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DVTABLES_IN_DRAM", + ) + config = _resolve_current() + assert config.nonosdk == "NONOSDK305" + assert config.exceptions + assert config.vtables == "VTABLES_IN_DRAM" + assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"] + + +def test_build_config_mmu_custom_requires_sizes() -> None: + + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM") + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + config = _resolve_current() + # Emitted pre-sorted so build.ninja stays byte-stable across runs + assert config.mmu_defines == [ + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_SIZE=0xC000", + ] + + +def test_defines_match_platformio_builder() -> None: + """The exact define set the PlatformIO builder passes for nodemcuv2/dout.""" + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert _defines_flags( + _resolve_current(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) == [ + "-DF_CPU=80000000L", + "-D__ets__", + "-DICACHE_FLASH", + "-D_GNU_SOURCE", + "-DARDUINO=10805", + '-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"', + '-DARDUINO_BOARD_ID=\\"nodemcuv2\\"', + "-DFLASHMODE_DOUT", + "-DLWIP_OPEN_SRC", + "-DNONOSDK22x_190703=1", + "-DTCP_MSS=1460", + "-DLWIP_FEATURES=0", + "-DLWIP_IPV6=0", + "-DVTABLES_IN_FLASH", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DESP8266", + "-DARDUINO_ARCH_ESP8266", + "-DARDUINO_ESP8266_NODEMCU_ESP12E", + ] + + +def _make_framework(tmp_path: Path) -> InstalledPaths: + framework = tmp_path / "framework" + core = framework / "cores" / "esp8266" + core.mkdir(parents=True) + for name in ( + "core_esp8266_main.cpp", + "Updater.cpp", + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", + "cont.S", + "abi.c", + ): + (core / name).write_text("") + (framework / "variants" / "nodemcu").mkdir(parents=True) + for sub in ("include", "ld", "lwip2/include", "lib"): + (framework / "tools" / "sdk" / sub).mkdir(parents=True) + (framework / "libraries").mkdir() + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + (toolchain / "include").mkdir() + return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) + + +@pytest.mark.parametrize( + ("knob", "lib", "mss", "features", "ipv6"), + [ + ("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH", + "lwip6-1460-feat", + 1460, + 1, + 1, + ), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0), + # LOW_MEMORY has no upstream branch: it is the default (else) variant + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", "lwip2-536-feat", 536, 1, 0), + ], +) +def test_build_config_lwip_variants( + knob: str, lib: str, mss: int, features: int, ipv6: int +) -> None: + """Every lwIP knob maps to the same defines and library as the PIO builder.""" + + config = _resolve(f"-D{knob}") + assert config.lwip_lib == lib + assert f"TCP_MSS={mss}" in config.knob_defines + assert f"LWIP_FEATURES={features}" in config.knob_defines + assert f"LWIP_IPV6={ipv6}" in config.knob_defines + + +def test_lwip_low_memory_loses_to_listed_knobs() -> None: + """The ordinary SNTP multi-server config: sntp emits LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH, and the listed knob must win + exactly as in platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip2-1460" + + +def test_lwip_ipv6_wins_over_default_knob() -> None: + """The shipping IPv6 config: network emits IPV6_LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH; IPv6 must win exactly as in + platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip6-536-feat" + assert "LWIP_IPV6=1" in config.knob_defines + + +def test_unrecognized_compile_flag_shape_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """A link-only spelling missing from the deny lists still reaches the + compile line, but no longer silently.""" + _set_flags("-shared") + compile_flags, _link_flags, _lib_dirs, _libs = _split_flags() + assert "-shared is not a recognized compile-flag shape" in caplog.text + assert "-shared" in compile_flags + + +@pytest.mark.parametrize( + ("knob", "expected"), + [ + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED", + ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED", + [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"], + ), + ], +) +def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None: + + _set_flags(f"-D{knob}") + assert _resolve_build_config(_defines()).mmu_defines == expected + + +def test_build_config_waveform_locked_phase() -> None: + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM") + assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines + assert config.fp_in_irom + + +_COMMON_LD_H_OUTPUT = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000 +} +SECTIONS +{ + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + } >dram0_0_seg :dram0_0_phdr +} +""" + + +def _run_generate_ld_scripts(paths: InstalledPaths) -> Path: + + config = _resolve_current() + arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld") + return CORE.relative_pioenvs_path(CORE.name, "ld") + + +def test_generate_ld_scripts(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + _set_flags("-DFP_IN_IROM") + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + patch.object(arduino8266._LOGGER, "warning") as mock_warn, + ): + ld_dir = _run_generate_ld_scripts(paths) + # A clean preprocessor run must be quiet + mock_warn.assert_not_called() + content = (ld_dir / "local.eagle.app.v6.common.ld").read_text() + assert RATETABLE_RULE in content + cmd = mock_run.call_args[0][0] + assert "-DVTABLES_IN_FLASH" in cmd + assert "-DMMU_IRAM_SIZE=0x8000" in cmd + assert "-DFP_IN_IROM" in cmd + + # Unchanged inputs skip the preprocessor spawn on the next run + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + + # A surgery edit invalidates the stamp via the fingerprint (a stale + # linker script would otherwise persist until an esphome clean) + with ( + patch.object( + arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" + ), + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + ): + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_corrupt_cache_regenerates(tmp_path: Path) -> None: + """A truncated cached linker script regenerates even with a fresh stamp.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text("truncated garbage") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert RATETABLE_RULE in output.read_text() + + +def test_generate_ld_scripts_failure(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + result = MagicMock(returncode=1, stderr=b"nope") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="linker script failed"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + # Real flash ld scripts carry no iram1_0_seg (that lives in the + # generated common ld); the surgery rejects one it was not asked about + "MEMORY\n{\n" + " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" + " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" + "}\n" + ) + CORE.testing_mode = True + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text() + assert "len = 0x2000000" in patched + + +def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( + tmp_path: Path, +) -> None: + """A missing flash ld in testing mode names the file and the fix.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="Could not read .*clean-all"), + ): + _run_generate_ld_scripts(paths) + + +def test_build_config_nonosdk_precedence() -> None: + """With two SDK knobs set (a pathological config), ties break + deterministically by table order.""" + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221", + ) + assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" + + +def test_project_flags_trailing_bare_linker_flag_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + _set_flags("-l") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert "Ignoring trailing '-l'" in caplog.text + assert not libs + assert not lib_dirs + assert "-l" not in compile_flags + assert "-l" not in link_flags + + +def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: + _set_flags("-L /d -Wl,-Map=m stray") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert lib_dirs == [Path("/d")] + assert link_flags == ["-Wl,-Map=m"] + assert "stray" in compile_flags + assert not libs + + +def test_flag_defines_lexes_multi_token_entries() -> None: + """A knob inside a multi-token entry is detected like PlatformIO does.""" + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert defines["FOO"] == "FOO=1" + config = _resolve_build_config(defines) + assert config.lwip_lib == "lwip2-1460" + + +def test_project_flags_lexes_every_entry() -> None: + """A linker flag anywhere in an entry reaches the link line (PIO parity).""" + _set_flags("-DFOO=1 -lbar") + compile_flags, _link, _dirs, libs = _split_flags() + assert libs == ["bar"] + assert "-DFOO=1" in compile_flags + + +def test_project_flags_unflags_match_tokens() -> None: + """build_unflags removes a token embedded in a multi-token entry.""" + _set_flags("-Os -g3") + CORE.build_unflags = {"-Os"} + compile_flags, _link, _dirs, _libs = _split_flags() + assert "-g3" in compile_flags + assert "-Os" not in compile_flags + + +def test_project_flags_requotes_lexed_defines() -> None: + """A quoted spaced value stays one compiler argument after lex/emit.""" + _set_flags('-DGREETING="hello world"') + compile_flags, _link, _dirs, _libs = _split_flags() + # shlex folds the quotes (as PIO's ParseFlags does); _shell_token + # re-quotes the spaced token so the shell passes one argv element + expected = ( + '"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'" + ) + assert compile_flags == [expected] + + +def test_flag_defines_joins_spaced_define() -> None: + """A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it.""" + _set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert "" not in defines + + +def test_build_config_custom_mmu_without_knob_raises() -> None: + """Custom MMU sizes without the CUSTOM knob are refused.""" + with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): + _resolve("-DMMU_IRAM_SIZE=0xC000") + + +def test_flag_defines_lexes_quoted_single_tokens() -> None: + """A quoted single-token define reads the same as on the compile line.""" + _set_flags('-DMMU_SEC_HEAP="0x40108000"') + assert _defines()["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000" + + +def test_flag_defines_duplicate_defines_resolve_deterministically() -> None: + """Duplicate conflicting defines pick the same winner every run (sorted + iteration, last writer wins), independent of the set's hash seed.""" + _set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000") + assert _defines()["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000" + + +def test_flag_tables_match_platformio_builder() -> None: + """The transliterated flag lists pinned verbatim, like the define set: + a drift lands as a test failure, not a binary-size regression.""" + assert arduino8266._ASFLAGS == ["-mlongcalls", "-mtext-section-literals"] + assert arduino8266._CFLAGS == [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", + ] + assert arduino8266._CCFLAGS == [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", + ] + # Pins the deliberate -u _scanf_float omission + assert arduino8266._LINKFLAGS == [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", + ] + # Order is load-bearing: upstream's LIBS order resolves symbols correctly + assert arduino8266._SYSTEM_LIBS_PRE_LWIP == ["hal", "phy", "pp", "net80211"] + assert arduino8266._SYSTEM_LIBS_POST_LWIP == [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", + ] + + +def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: + """A half-deleted toolchain cache fails with an ESPHome error naming the + binary, not a FileNotFoundError traceback.""" + paths = _make_framework(tmp_path) + _set_flags() + with pytest.raises(EsphomeError, match="Could not run"): + _run_generate_ld_scripts(paths) + + +def test_unflag_tokens_join_spaced_entries() -> None: + """Spaced build_unflags entries ("-D FOO") match the joined token.""" + CORE.build_unflags = {"-D FOO", "-l bar"} + tokens = arduino8266._unflag_tokens() + assert tokens == {"-DFOO", "-lbar"} + CORE.build_flags = {"-DFOO -lbar", "-DBAR"} + compile_flags, _link, _dirs, libs = arduino8266._project_flags( + tokens, arduino8266._lexed_build_flags() + ) + assert compile_flags == ["-DBAR"] + assert libs == [] + + +def test_flag_defines_respects_unflags() -> None: + """An unflagged knob must not drive the derived toolchain config.""" + _set_flags("-DVTABLES_IN_DRAM") + defines = _flag_defines({"-DVTABLES_IN_DRAM"}, arduino8266._lexed_build_flags()) + assert "VTABLES_IN_DRAM" not in defines + config = _resolve_build_config(defines) + assert config.vtables == "VTABLES_IN_FLASH" + + +def test_vtables_unknown_raises() -> None: + """An unknown VTABLES_IN_* knob fails by name.""" + with pytest.raises(EsphomeError, match="Unknown VTABLES_IN_.*BANANA"): + _resolve("-DVTABLES_IN_BANANA") + + +def test_vtables_conflicting_raises() -> None: + with pytest.raises(EsphomeError, match="Conflicting VTABLES_IN_"): + _resolve("-DVTABLES_IN_DRAM", "-DVTABLES_IN_IRAM") + + +def test_empty_lib_flags_warned_and_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """A bare -L would silently add the CWD to the search path; the lex + funnel warns and drops it for every consumer.""" + CORE.build_flags = {'-L ""', '-l ""', "-DFOO"} + assert arduino8266._lexed_build_flags() == ["-DFOO"] + assert "Ignoring '-L' with empty argument" in caplog.text + assert "Ignoring '-l' with empty argument" in caplog.text + + +def test_generate_ld_scripts_surfaces_preprocessor_warnings( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Preprocessor stderr on a zero exit reaches the user; degenerate output is refused.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "Linker-script preprocessor: warning: something" in caplog.text + + # New flags invalidate the stamp so the degenerate run regenerates + _set_flags("-DVTABLES_IN_DRAM") + result = _ok_result(stdout="") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="SECTIONS"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_lost_warn_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A warn note that could not persist skips the stamp, so the next build + re-runs -E and re-derives the diagnostic instead of losing it.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + real_write_text = Path.write_text + + def fail_note_writes(self: Path, text: str, encoding: str = "utf-8") -> int: + if self.name.endswith(".stderr"): + raise OSError("read-only build dir") + return real_write_text(self, text, encoding=encoding) + + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as run1, + patch.object(Path, "write_text", fail_note_writes), + ): + _run_generate_ld_scripts(paths) + run1.assert_called_once() + assert "Could not write" in caplog.text + + # Unstamped: the second build re-runs the preprocessor + with patch.object(arduino8266.subprocess, "run", return_value=result) as run2: + _run_generate_ld_scripts(paths) + run2.assert_called_once() + assert caplog.text.count("Linker-script preprocessor: warning: something") == 2 + + +def test_generate_ld_scripts_unremovable_stale_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stale warn note that cannot be removed skips the stamp, so the + obsolete diagnostic is not re-emitted on cache hits forever.""" + paths = _make_framework(tmp_path) + _set_flags() + warn = _ok_result(stderr="warning: old") + clean = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=warn): + _run_generate_ld_scripts(paths) + + real_unlink = Path.unlink + + def fail_note_unlink(self: Path, missing_ok: bool = False) -> None: + if self.name.endswith(".stderr"): + raise OSError("locked") + real_unlink(self, missing_ok=missing_ok) + + # Flags changed -> regenerate; clean stderr but the stale note is stuck + _set_flags("-DVTABLES_IN_DRAM") + with ( + patch.object(arduino8266.subprocess, "run", return_value=clean), + patch.object(Path, "unlink", fail_note_unlink), + ): + _run_generate_ld_scripts(paths) + # Unstamped: the next build re-runs -E instead of trusting the cache + with patch.object(arduino8266.subprocess, "run", return_value=clean) as run3: + _run_generate_ld_scripts(paths) + run3.assert_called_once() + + +@pytest.mark.parametrize("name", ["MMU_IRAM_SIZE", "MMU_ICACHE_SIZE"]) +def test_mmu_custom_valueless_segment_size_raises(name: str) -> None: + """A bare -Dname would preprocess to len = 1 and fail far away in ld.""" + other = "MMU_ICACHE_SIZE" if name == "MMU_IRAM_SIZE" else "MMU_IRAM_SIZE" + with pytest.raises(EsphomeError, match=f"{name} must be a hex literal"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", f"-D{name}", f"-D{other}=0x8000") + + +@pytest.mark.parametrize( + "flag", ["-fuse-ld=lld", "--specs=nano.specs", "-specs=nano.specs"] +) +def test_driver_link_flags_rejected_without_wl_advice(flag: str) -> None: + """No -Wl, spelling exists for these; the message must not suggest one.""" + CORE.build_flags = {flag, "-DFOO"} + with pytest.raises(EsphomeError, match="not supported by the native toolchain"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_build_config_mmu_knob_with_raw_mmu_flag_raises() -> None: + """A variant knob plus a raw MMU_* define would split the compile line + from the linker script; refuse like the no-knob case.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE conflict with .*CACHE16"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", "-DMMU_IRAM_SIZE=0x4000") + + +def test_build_config_raw_lwip_define_raises() -> None: + """TCP_MSS/LWIP_* belong to the lwIP knobs: a raw value would win the + compile line while the prebuilt library stays the knob's.""" + with pytest.raises(EsphomeError, match="TCP_MSS are set by the .*LWIP2"): + _resolve("-DTCP_MSS=1024") + + +def test_build_config_mmu_defines_do_not_alias_the_table() -> None: + """The resolved list must be a copy; mutating it must not corrupt the + module table for later builds in the same process.""" + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48") + config.mmu_defines.append("MMU_BOGUS") + again = _resolve_current() + assert "MMU_BOGUS" not in again.mmu_defines + assert all(isinstance(v, tuple) for v in arduino8266._MMU_VARIANTS.values()) + + +def test_lexed_build_flags_shared_between_consumers( + caplog: pytest.LogCaptureFixture, +) -> None: + """Lexing once and passing the tokens to both consumers yields the same + result as each lexing itself, with a malformed entry warned once.""" + _set_flags("-DFOO=1 -l", "-Wl,--wrap=x") + tokens = arduino8266._lexed_build_flags() + assert caplog.text.count("Ignoring trailing '-l'") == 1 + assert _flag_defines(set(), tokens) == _flag_defines( + set(), arduino8266._lexed_build_flags() + ) + assert arduino8266._project_flags(set(), tokens) == arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + + +@pytest.mark.parametrize( + "tok", ["-Tcustom.ld", "-Xlinker", "-u", "-e", "-s", "-static", "-nostartfiles"] +) +def test_project_flags_rejects_plain_linker_forms(tok: str) -> None: + """Plain-form linker flags are refused, naming the -Wl, form.""" + _set_flags(tok) + with pytest.raises(EsphomeError, match="use the -Wl, form"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_project_flags_plain_compile_flags_pass() -> None: + _set_flags("-Os") + compile_flags, _l, _d, _libs = arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + assert "-Os" in compile_flags + + +def test_generate_ld_scripts_header_change_invalidates_stamp( + tmp_path: Path, +) -> None: + """An in-place framework edit at the same path regenerates the script.""" + paths = _make_framework(tmp_path) + header = paths.framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h" + header.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + header.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> None: + """A non-UTF-8 stamp is a damaged cache: regenerate, never abort.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + (ld_dir / ".local.eagle.app.v6.common.ld.stamp").write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_vtables_valued_define_raises() -> None: + """A VTABLES_IN_* body would split the compile line from the linker + script, which always defines the bare name.""" + with pytest.raises(EsphomeError, match="take no value.*VTABLES_IN_FLASH=0"): + _resolve("-DVTABLES_IN_FLASH=0") + + +def test_defines_flags_invalid_board_raises() -> None: + """The board name lands unquoted in two -D bodies; reject it by name.""" + with pytest.raises(EsphomeError, match="Invalid board name"): + _defines_flags(_resolve(), "dout", "evil board", ()) + + +def test_generate_ld_scripts_invalid_flash_ld_name_raises(tmp_path: Path) -> None: + """The script name joins under the SDK and build ld dirs; a traversal + or path is rejected by name.""" + paths = _make_framework(tmp_path) + _set_flags() + config = _resolve() + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.generate_ld_scripts(paths, config, "../evil.ld") + + +def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None: + """The stamp records the content hash, so an externally edited cached + script regenerates instead of linking untrusted content.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text(output.read_text() + "\n/* tampered */\n") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert "tampered" not in output.read_text() + + +def test_generate_ld_scripts_corrupt_output_is_overwritten(tmp_path: Path) -> None: + """A non-UTF-8 cached script must be overwritten by the regeneration, + not abort it (write_file_if_changed reads the old content).""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "SECTIONS" in output.read_text(encoding="utf-8") + + +def test_generate_ld_scripts_non_utf8_output_raises(tmp_path: Path) -> None: + """A non-UTF-8 byte in the preprocessed script fails by header name; a + U+FFFD-mangled script must never be cached as valid.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout=b"SECTIONS { }\xff\xfe") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="is not UTF-8"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_note_read_race_still_warns( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A note that turns unreadable between the digest check and the + re-emit read (TOCTOU) warns by path instead of dropping silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + orig = Path.read_text + + def flaky(self, *args, **kwargs): + if self.name.endswith(".stderr"): + raise OSError("EIO") + return orig(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", flaky) + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "could not be read" in caplog.text + + +@pytest.mark.parametrize("damage", ["corrupt", "remove"]) +def test_generate_ld_scripts_damaged_note_invalidates_cache( + tmp_path: Path, caplog: pytest.LogCaptureFixture, damage: str +) -> None: + """A corrupted or externally removed diagnostic note is a cache miss: + -E re-runs and re-derives the warning instead of dropping it silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + note = ld_dir / ".local.eagle.app.v6.common.ld.stderr" + if damage == "corrupt": + note.write_bytes(b"\xff\xfe") + else: + note.unlink() + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + assert mock_run.called + assert "Linker-script preprocessor: warn!" in caplog.text + + +@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "0x10UL"]) +def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + f"-DMMU_IRAM_SIZE={value}", + "-DMMU_ICACHE_SIZE=0x8000", + ) + assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines + + +@pytest.mark.parametrize( + "flag", + [ + "-DMMU_IRAM_SIZE=48K", + # Decimal passes preprocessing but build_surgery's segment parser + # only reads hex, so testing-mode surgery would fail misleadingly + "-DMMU_IRAM_SIZE=32768", + ], +) +def test_mmu_custom_malformed_size_raises(flag: str) -> None: + """A non-hex size would corrupt the preprocessed segment lengths (or + defeat the testing-mode surgery); refuse by name.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a hex"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + flag, + "-DMMU_ICACHE_SIZE=0x8000", + ) + + +def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: + """Valueless MMU switches (MMU_IRAM_HEAP) pass; every valued MMU_* is + hex-validated, not just the two required sizes.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_IRAM_HEAP", + ) + assert "MMU_IRAM_HEAP" in config.mmu_defines + with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a numeric"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_SEC_HEAP_SIZE=48K", + ) + + +def test_mmu_custom_accepts_decimal_non_segment_values() -> None: + """MMU_EXTERNAL_HEAP=128 (the module's own EXTERNAL_128K shape) is a + mmu_iram.h count, not a segment length; decimal is legal there while + the two segment sizes stay hex-only for the surgery parser.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_EXTERNAL_HEAP=128", + ) + assert "MMU_EXTERNAL_HEAP=128" in config.mmu_defines + + +def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None: + """The no-knob branch refuses every raw MMU_*, like the knob branch; a + lone switch would win the compile line but not the linker script.""" + with pytest.raises(EsphomeError, match="Raw MMU_IRAM_HEAP"): + _resolve("-DMMU_IRAM_HEAP") + + +def test_raw_nonosdk_define_raises() -> None: + """A raw NONOSDK* define would split the compile line from the linked + SDK libraries, like the lwIP knob overrides.""" + with pytest.raises(EsphomeError, match="NONOSDK305 are set by the"): + _resolve("-DNONOSDK305=1") + + +def test_write_note_warn_level( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A lost stderr note drops a diagnostic on later cached builds, so it + warns; a lost stamp only costs a cache miss.""" + arduino8266._write_note(tmp_path / "missing" / "note", "x", warn=True) + assert "Could not write" in caplog.text + + +def test_write_note_failure_is_best_effort( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed stamp or stderr-note write costs a cache miss, never the + build.""" + caplog.set_level("DEBUG") + arduino8266._write_note(tmp_path / "missing" / "stamp", "x") + assert "Could not write" in caplog.text + + +def test_pio_option_blank_value_raises() -> None: + """An empty or blank platformio_options value is a config error, not a + silent fallback to the default.""" + CORE.platformio_options = {"board_build.f_cpu": " "} + with pytest.raises(EsphomeError, match="board_build.f_cpu is empty"): + arduino8266._pio_option("board_build.f_cpu", "80000000L") + + +@pytest.mark.parametrize("value", ["160 MHz", "\u0661\u0666\u0660"]) +def test_defines_flags_invalid_f_cpu_raises(value: str) -> None: + """A non-numeric board_build.f_cpu is rejected by name; it would land + unquoted on the compile line. Unicode digits count as non-numeric: the + shape checks are ASCII-only.""" + CORE.platformio_options = {"board_build.f_cpu": value} + with pytest.raises(EsphomeError, match="Invalid board_build.f_cpu"): + _defines_flags( + _resolve(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) + + +def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: + """A moved rate-table anchor surfaces as a build error, not a traceback + or a silently unrelocated table.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout="SECTIONS { no anchor here }") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="anchor not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: + """With MMU_CUSTOM also set, the actionable fix is dropping the variant + knob, not setting the knob the user already set.""" + with pytest.raises(EsphomeError, match="drop PIO_FRAMEWORK_ARDUINO_MMU_CACHE16"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + + +def test_generate_ld_scripts_testing_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """A testing-mode segment patch failing on a changed linker script is a + named error, like the ratetable surgery.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=RuntimeError("iram1_0_seg not found"), + ), + pytest.raises(EsphomeError, match="iram1_0_seg not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_flash_ld_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """The flash-ld segment patch gets the same named-error wrap.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + "MEMORY { }" + ) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=["patched common", RuntimeError("dram0_0_seg mismatch")], + ), + pytest.raises(EsphomeError, match="dram0_0_seg mismatch"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_reemits_cached_preprocessor_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A preprocessor diagnostic survives cache hits instead of appearing + once and vanishing for the life of the build dir.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warning: something odd") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert caplog.text.count("warning: something odd") == 1 + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + assert caplog.text.count("warning: something odd") == 2 + + +def test_generate_ld_scripts_unreadable_header_forces_regeneration( + tmp_path: Path, +) -> None: + """A stat failure other than absence must miss the cache every run, not + pin the stamp to a constant that can never notice a later edit.""" + paths = _make_framework(tmp_path) + header_name = "eagle.app.v6.common.ld.h" + (paths.framework / "tools" / "sdk" / "ld" / header_name).write_text("v1") + real_stat = Path.stat + + def fake_stat(self: Path, **kwargs: object): + if self.name == header_name: + raise PermissionError(13, "denied") + return real_stat(self, **kwargs) + + result = _ok_result() + with patch.object(Path, "stat", fake_stat): + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_bare_include_and_define_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """An empty-argument -I or -D would make gcc eat the next flag as the + argument; the lex funnel warns and drops both.""" + CORE.build_flags = {'-I ""', '-D ""'} + assert arduino8266._lexed_build_flags() == [] + assert "Ignoring '-I' with empty argument" in caplog.text + assert "Ignoring '-D' with empty argument" in caplog.text + + +def test_generate_ld_scripts_gcc_change_invalidates_stamp(tmp_path: Path) -> None: + """An in-place toolchain re-extraction regenerates the script, same as + the header stat.""" + paths = _make_framework(tmp_path) + gcc = toolchain_tool(paths.toolchain, "gcc") + gcc.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + gcc.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_defines_flags_honors_f_cpu_override() -> None: + """board_build.f_cpu (a published-config overclock knob) reaches the + compile line; the default stays 80 MHz.""" + _set_flags() + config = _resolve_build_config(_flag_defines(set(), [])) + board_build = ESP8266_BOARD_BUILD["nodemcuv2"] + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=80000000L" in defines + CORE.platformio_options = {"board_build.f_cpu": "160000000L"} + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=160000000L" in defines From 5e8f6203628e92082ecfd4f04356b4ab43d4d237 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:53:34 +0100 Subject: [PATCH 251/316] [voice_assistant] Use register_apply_action for start, start_continuous and stop (#19536) --- .../components/voice_assistant/__init__.py | 59 +++++-------------- .../voice_assistant/voice_assistant.h | 25 -------- .../voice_assistant/common-idf.yaml | 3 + tests/components/voice_assistant/common.yaml | 3 + 4 files changed, 21 insertions(+), 69 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d30eaf4768..d66ef43fdc 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import register_action, register_condition +from esphome.automation import register_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -62,15 +62,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -StartAction = voice_assistant_ns.class_( - "StartAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StartContinuousAction = voice_assistant_ns.class_( - "StartContinuousAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StopAction = voice_assistant_ns.class_( - "StopAction", automation.Action, cg.Parented.template(VoiceAssistant) -) IsRunningCondition = voice_assistant_ns.class_( "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) ) @@ -406,51 +397,31 @@ async def to_code(config: ConfigType) -> None: VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssistant)}) -@register_action( +automation.register_apply_action( "voice_assistant.start_continuous", - StartContinuousAction, VOICE_ASSISTANT_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("request_start(true, true)"), ) -@register_action( +# wake_word defaults to "" so a start without one clears the previous wake word, +# as the old action did. +automation.register_apply_action( "voice_assistant.start", - StartAction, VOICE_ASSISTANT_ACTION_SCHEMA.extend( { cv.Optional(CONF_SILENCE_DETECTION, default=True): cv.boolean, - cv.Optional(CONF_WAKE_WORD): cv.templatable(cv.string), + cv.Optional(CONF_WAKE_WORD, default=""): cv.templatable(cv.string), } ), - synchronous=True, + automation.ApplyField(CONF_WAKE_WORD, "set_wake_word", cg.std_string), + automation.ApplyCall( + "request_start(false, {})", ((CONF_SILENCE_DETECTION, cg.bool_),) + ), ) -async def voice_assistant_listen_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if CONF_SILENCE_DETECTION in config: - cg.add(var.set_silence_detection(config[CONF_SILENCE_DETECTION])) - if wake_word := config.get(CONF_WAKE_WORD): - templ = await cg.templatable(wake_word, args, cg.std_string) - cg.add(var.set_wake_word(templ)) - return var - - -@register_action( - "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "voice_assistant.stop", + VOICE_ASSISTANT_ACTION_SCHEMA, + automation.ApplyCall("request_stop()"), ) -async def voice_assistant_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @register_condition( diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index d46b089c2e..88ddb2066a 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -347,31 +347,6 @@ class VoiceAssistant final : public Component { #endif }; -template class StartAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, wake_word); - - public: - void play(const Ts &...x) override { - this->parent_->set_wake_word(this->wake_word_.value(x...)); - this->parent_->request_start(false, this->silence_detection_); - } - - void set_silence_detection(bool silence_detection) { this->silence_detection_ = silence_detection; } - - protected: - bool silence_detection_; -}; - -template class StartContinuousAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_start(true, true); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_stop(); } -}; - template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 812e7a2314..998ba90dd9 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 8604bea795..5a5791899e 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop From 4e2d1cd7fc205cddbd3873b2c3ad2f66c53c6c95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:55:16 +0100 Subject: [PATCH 252/316] [hlk_fm22x] Use register_apply_action for scan, enroll, delete, delete_all and reset (#19537) --- esphome/components/hlk_fm22x/__init__.py | 63 +++++------------------- esphome/components/hlk_fm22x/hlk_fm22x.h | 37 -------------- tests/components/hlk_fm22x/common.yaml | 7 +++ 3 files changed, 20 insertions(+), 87 deletions(-) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index c1aa81f6d4..b8040e5579 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -27,11 +27,6 @@ HlkFm22xComponent = hlk_fm22x_ns.class_( "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) -DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) -ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action) -ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -101,9 +96,8 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.enroll", - EnrollmentAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -112,22 +106,14 @@ async def to_code(config): }, key=CONF_NAME, ), - synchronous=True, + automation.ApplyCall( + "enroll_face({}, static_cast({}))", + ((CONF_NAME, cg.std_string), (CONF_DIRECTION, cg.uint8)), + ), ) -async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string) - cg.add(var.set_name(template_)) - template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8) - cg.add(var.set_direction(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete", - DeleteAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -135,60 +121,37 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): }, key=CONF_FACE_ID, ), - synchronous=True, + automation.ApplyField(CONF_FACE_ID, "delete_face", cg.int16), ) -async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16) - cg.add(var.set_face_id(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete_all", - DeleteAllAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("delete_all_faces()"), ) -async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.scan", - ScanAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("scan_face()"), ) -async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.reset", - ResetAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) -async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 3bdf6e2c71..8b219f3ed2 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -141,41 +141,4 @@ class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, name) - TEMPLATABLE_VALUE(uint8_t, direction) - - void play(const Ts &...x) override { - auto name = this->name_.value(x...); - auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); - this->parent_->enroll_face(name, direction); - } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int16_t, face_id) - - void play(const Ts &...x) override { - auto face_id = this->face_id_.value(x...); - this->parent_->delete_face(face_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_faces(); } -}; - -template class ScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->scan_face(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - } // namespace esphome::hlk_fm22x diff --git a/tests/components/hlk_fm22x/common.yaml b/tests/components/hlk_fm22x/common.yaml index 6fcd9af594..1371bd8e95 100644 --- a/tests/components/hlk_fm22x/common.yaml +++ b/tests/components/hlk_fm22x/common.yaml @@ -4,11 +4,18 @@ esphome: - hlk_fm22x.enroll: name: "Test" direction: 1 + - hlk_fm22x.delete: 5 - hlk_fm22x.delete_all: + - hlk_fm22x.reset: + - hlk_fm22x.scan: hlk_fm22x: on_face_scan_matched: - logger.log: test_hlk_22x_face_scan_matched + - hlk_fm22x.enroll: + name: !lambda 'return name + "-again";' + direction: !lambda 'return face_id % 3;' + - hlk_fm22x.delete: !lambda 'return face_id;' on_face_scan_unmatched: - logger.log: test_hlk_22x_face_scan_unmatched on_face_scan_invalid: From 01f9416a79c025ebb9cb2468d23fa9e547178253 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:55:58 +0100 Subject: [PATCH 253/316] [text_sensor] Use register_apply_action for text_sensor.template.publish (#19539) --- esphome/components/template/text_sensor/__init__.py | 12 ++---------- esphome/components/text_sensor/__init__.py | 3 --- esphome/components/text_sensor/automation.h | 11 ----------- tests/components/text_sensor/common.yaml | 11 +++++++++++ 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index 77f5c2ff7c..32e616e6f9 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -1,7 +1,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import text_sensor -from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE @@ -38,20 +37,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "text_sensor.template.publish", - TextSensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(text_sensor.TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.std_string), ) -async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 5c8d71696f..35e33f237c 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -43,9 +43,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorPublishAction = text_sensor_ns.class_( - "TextSensorPublishAction", automation.Action -) TextSensorStateCondition = text_sensor_ns.class_( "TextSensorStateCondition", automation.Condition ) diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index 628b9b84a0..ac130afa33 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -34,15 +34,4 @@ template class TextSensorStateCondition final : public Condition TextSensor *parent_; }; -template class TextSensorPublishAction final : public Action { - public: - TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(std::string, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - TextSensor *sensor_; -}; - } // namespace esphome::text_sensor diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index 97b0b8ad94..ce1dd3a3f2 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -77,3 +77,14 @@ text_sensor: return {x.substr(0, 10) + "..."}; } return {x}; + + - platform: template + name: "Test Publish Action" + id: test_publish_action + on_value: + - text_sensor.template.publish: + id: test_map_single + state: "ON" + - text_sensor.template.publish: + id: test_map_single + state: !lambda 'return x + " again";' From bbd8472b66d5607df8fb8e89c05e6d06dd8878c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:57:32 +0100 Subject: [PATCH 254/316] [espnow] Use register_apply_action for peer.add, peer.delete and set_channel (#19538) --- esphome/components/espnow/__init__.py | 72 +++++++------------- esphome/components/espnow/automation.h | 33 --------- esphome/components/espnow/espnow_component.h | 9 +++ tests/components/espnow/common.yaml | 7 ++ 4 files changed, 41 insertions(+), 80 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 14d099ec06..c04cf1ed59 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -39,9 +39,6 @@ ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") SendAction = espnow_ns.class_("SendAction", automation.Action) -SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) -AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) -DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, @@ -232,12 +229,16 @@ def _validate_raw_data(value: Any) -> str | list: ) +def _mac_bytes(address: core.MACAddress) -> list[HexInt]: + return [HexInt(p) for p in address.parts] + + async def register_peer( var: MockObj, config: ConfigType, args: TemplateArgsType ) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): - peer = [HexInt(p) for p in peer.parts] + peer = _mac_bytes(peer) template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) cg.add(var.set_address(template_)) @@ -323,40 +324,28 @@ async def send_action( return var -@automation.register_action( - "espnow.peer.add", - AddPeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -@automation.register_action( - "espnow.peer.delete", - DeletePeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -async def peer_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await register_peer(var, config, args) - - return var +def _peer_address(config: ConfigType, value: core.MACAddress) -> str: + return str(cg.safe_exp(_mac_bytes(value))) -@automation.register_action( +for _name, _method in ( + ("espnow.peer.add", "add_peer_from_action"), + ("espnow.peer.delete", "del_peer_from_action"), +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), + automation.ApplyField( + CONF_ADDRESS, _method, peer_address_t, const_fn=_peer_address + ), + ) + + +automation.register_apply_action( "espnow.set_channel", - SetChannelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(ESPNowComponent), @@ -364,16 +353,5 @@ async def peer_action( }, key=CONF_CHANNEL, ), - synchronous=True, + automation.ApplyField(CONF_CHANNEL, "set_channel_from_action", cg.uint8), ) -async def channel_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index e4d01bb1a8..3c9c6e8be9 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -86,39 +86,6 @@ template class SendAction final : public Action, public P } flags_{0}; }; -template class AddPeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->add_peer(address.data()); - } -}; - -template class DeletePeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->del_peer(address.data()); - } -}; - -template class SetChannelAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, channel) - - protected: - void play(const Ts &...x) override { - if (this->parent_->is_wifi_enabled()) { - return; - } - this->parent_->set_wifi_channel(this->channel_.value(x...)); - this->parent_->apply_wifi_channel(); - } -}; - class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index af693b47cf..375bd4faef 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -116,9 +116,18 @@ class ESPNowComponent final : public Component { esp_err_t add_peer(const uint8_t *peer); // Remove a peer with the esp_now api and remove from the internal list if exists esp_err_t del_peer(const uint8_t *peer); + // Action entry points; distinct names because add_peer(peer_address_t) only fills the boot-time list + esp_err_t add_peer_from_action(const peer_address_t &address) { return this->add_peer(address.data()); } + esp_err_t del_peer_from_action(const peer_address_t &address) { return this->del_peer(address.data()); } void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } void apply_wifi_channel(); + void set_channel_from_action(uint8_t channel) { + if (this->is_wifi_enabled()) + return; + this->set_wifi_channel(channel); + this->apply_wifi_channel(); + } uint8_t get_wifi_channel(); void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 2f82e794c4..fec0dd9f3e 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -38,8 +38,15 @@ espnow: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' - espnow.peer.add: address: 11:22:33:44:55:66 + - espnow.peer.add: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' - espnow.peer.delete: address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' + - espnow.set_channel: 6 + - espnow.set_channel: + channel: !lambda 'return 6;' on_broadcast: - logger.log: format: "Broadcast from: %s = '%s' RSSI: %d" From 2e809c3626dd485b667328ed6b13772a03dfec64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:13:44 +0100 Subject: [PATCH 255/316] [pulse_counter] Use register_apply_action for pulse_counter.set_total_pulses (#19532) --- esphome/components/pulse_counter/automation.h | 21 ----------------- esphome/components/pulse_counter/sensor.py | 23 +++---------------- tests/components/pulse_counter/common.yaml | 8 +++++++ 3 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 esphome/components/pulse_counter/automation.h diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h deleted file mode 100644 index 380ef02304..0000000000 --- a/esphome/components/pulse_counter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_counter/pulse_counter_sensor.h" - -namespace esphome::pulse_counter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_counter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseCounterSensor *pulse_counter_; -}; - -} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 7c5a0590d7..677cfb6a5e 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -21,8 +21,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -41,10 +40,6 @@ PulseCounterSensor = pulse_counter_ns.class_( "PulseCounterSensor", sensor.Sensor, cg.PollingComponent ) -SetTotalPulsesAction = pulse_counter_ns.class_( - "SetTotalPulsesAction", automation.Action -) - def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) @@ -150,25 +145,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_counter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseCounterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/tests/components/pulse_counter/common.yaml b/tests/components/pulse_counter/common.yaml index 556b43ee6f..2b27d5467b 100644 --- a/tests/components/pulse_counter/common.yaml +++ b/tests/components/pulse_counter/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: pulse_counter + id: pulse_counter_sensor name: Pulse Counter pin: 4 count_mode: @@ -7,3 +8,10 @@ sensor: falling_edge: DECREMENT internal_filter: 13us update_interval: 15s + on_value: + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: 12345 + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: !lambda return (uint32_t) x; From 761dd9dd9cc68849cd60a1d861190e4ad50a18d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:10 +0100 Subject: [PATCH 256/316] [rp2_ble_tracker] Use register_apply_action for stop_scan (#19524) --- esphome/components/rp2_ble_tracker/__init__.py | 15 ++------------- esphome/components/rp2_ble_tracker/automation.h | 5 ----- .../rp2_ble_tracker/test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index b744aee31c..12857a556f 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -42,7 +42,6 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( ) StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -156,9 +155,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "rp2_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -166,14 +164,5 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rp2_ble_tracker/automation.h b/esphome/components/rp2_ble_tracker/automation.h index a3cede236a..7c64975077 100644 --- a/esphome/components/rp2_ble_tracker/automation.h +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::rp2_ble_tracker #endif // USE_RP2 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 fdcf2bcf75..1106fd01eb 100644 --- a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -37,12 +37,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 8f14adaa0c1afa0790e4d00700094aaace86df9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:25 +0100 Subject: [PATCH 257/316] [bk72xx_ble_tracker] Use register_apply_action for stop_scan (#19522) --- esphome/components/bk72xx_ble_tracker/__init__.py | 15 ++------------- .../components/bk72xx_ble_tracker/automation.h | 5 ----- .../test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 96b3536601..55013d4087 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -53,7 +53,6 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( ) StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -121,9 +120,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "bk72xx_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -131,17 +129,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h index 9017d19d71..f785b3eb3a 100644 --- a/esphome/components/bk72xx_ble_tracker/automation.h +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -38,11 +38,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::bk72xx_ble_tracker #endif // USE_LIBRETINY 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 777ae76b4f..3f5280d965 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -39,12 +39,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 4f6af3cfabaed01eb9e48a0a69d9c718b3f57ec1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:51 +0100 Subject: [PATCH 258/316] [alarm_control_panel] Use register_apply_action for arm_away, arm_home, arm_night and disarm (#19521) --- .../alarm_control_panel/__init__.py | 76 ++++--------------- .../alarm_control_panel/automation.h | 48 ------------ .../alarm_control_panel/common.yaml | 25 ++++++ 3 files changed, 39 insertions(+), 110 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 2f5d4c7c2b..001ecd8861 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -41,10 +41,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action) -ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action) -ArmNightAction = alarm_control_panel_ns.class_("ArmNightAction", automation.Action) -DisarmAction = alarm_control_panel_ns.class_("DisarmAction", automation.Action) PendingAction = alarm_control_panel_ns.class_("PendingAction", automation.Action) TriggeredAction = alarm_control_panel_ns.class_("TriggeredAction", automation.Action) ChimeAction = alarm_control_panel_ns.class_("ChimeAction", automation.Action) @@ -196,64 +192,20 @@ async def new_alarm_control_panel(config, *args): return var -@automation.register_action( - "alarm_control_panel.arm_away", - ArmAwayAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_home", - ArmHomeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_night", - ArmNightAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_CODE in config: - templatable_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.disarm", - DisarmAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_disarm_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var +# Mirrors AlarmControlPanel::arm_with_code_: arm first, set the code only when given. +for _name, _arm in ( + ("alarm_control_panel.arm_away", "arm_away()"), + ("alarm_control_panel.arm_home", "arm_home()"), + ("alarm_control_panel.arm_night", "arm_night()"), + ("alarm_control_panel.disarm", "disarm()"), +): + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_arm), + automation.ApplyField(CONF_CODE, "set_code", cg.std_string), + call="make_call", + ) @automation.register_action( diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index dcb5121c60..cd9b5eadce 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,54 +27,6 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction final : public Action { - public: - explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_away(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmHomeAction final : public Action { - public: - explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_home(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmNightAction final : public Action { - public: - explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_night(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class DisarmAction final : public Action { - public: - explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->disarm(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6ca..f9c26ec5c9 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -67,3 +67,28 @@ alarm_control_panel: on_cleared: then: - logger.log: "### CLEARED ###" + +button: + # Exercise the arm/disarm actions with a constant code, a lambda code and no code. + - platform: template + name: Alarm Panel Actions + on_press: + - alarm_control_panel.arm_away: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_home: + id: alarmcontrolpanel1 + code: !lambda |- + std::string code = "56"; + code += "78"; + return code; + - alarm_control_panel.arm_night: + id: alarmcontrolpanel1 + code: "0000" + - alarm_control_panel.disarm: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_away: alarmcontrolpanel2 + - alarm_control_panel.arm_home: alarmcontrolpanel2 + - alarm_control_panel.arm_night: alarmcontrolpanel2 + - alarm_control_panel.disarm: alarmcontrolpanel2 From 170bb02fa35328bb841dbf0a18305496e5d23f0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:16:08 +0100 Subject: [PATCH 259/316] [ln882h_ble_tracker] Use register_apply_action for stop_scan (#19523) --- esphome/components/ln882h_ble_tracker/__init__.py | 15 ++------------- .../components/ln882h_ble_tracker/automation.h | 5 ----- .../test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 4bfaa93ab7..69a01ae1ac 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -37,7 +37,6 @@ LN882HBLETracker = ln882h_ble_tracker_ns.class_( ) StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -103,9 +102,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "ln882h_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -113,17 +111,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h index 8b211384a0..98e58200fd 100644 --- a/esphome/components/ln882h_ble_tracker/automation.h +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY 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 608a4c6694..c1ef283ea3 100644 --- a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -34,12 +34,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 0110215e31ffde1da44d3f3b9ebe8f48a2701624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:20:05 +0100 Subject: [PATCH 260/316] [template] Use register_apply_action for lock.template.publish and valve.template.publish (#19525) --- esphome/components/template/lock/__init__.py | 18 ++--------- esphome/components/template/lock/automation.h | 16 ---------- esphome/components/template/valve/__init__.py | 32 ++++++------------- .../components/template/valve/automation.h | 22 ------------- tests/components/template/common-base.yaml | 15 +++++++++ 5 files changed, 26 insertions(+), 77 deletions(-) delete mode 100644 esphome/components/template/lock/automation.h delete mode 100644 esphome/components/template/valve/automation.h diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index d8bd9d16c6..95a359c67c 100644 --- a/esphome/components/template/lock/__init__.py +++ b/esphome/components/template/lock/__init__.py @@ -4,7 +4,6 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, - CONF_ID, CONF_LAMBDA, CONF_LOCK_ACTION, CONF_OPEN_ACTION, @@ -17,12 +16,6 @@ from .. import template_ns TemplateLock = template_ns.class_("TemplateLock", lock.Lock, cg.Component) -TemplateLockPublishAction = template_ns.class_( - "TemplateLockPublishAction", - automation.Action, - cg.Parented.template(TemplateLock), -) - def validate(config): if not config[CONF_OPTIMISTIC] and ( @@ -80,9 +73,8 @@ async def to_code(config): cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) -@automation.register_action( +automation.register_apply_action( "lock.template.publish", - TemplateLockPublishAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(TemplateLock), @@ -90,11 +82,5 @@ async def to_code(config): }, key=CONF_STATE, ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", lock.LockState), ) -async def lock_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, lock.LockState) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h deleted file mode 100644 index a979291b78..0000000000 --- a/esphome/components/template/lock/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "template_lock.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateLockPublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(lock::LockState, state) - - void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 11b35dad23..a7fd6f7632 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, CONF_DEVICE_CLASS, - CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, CONF_OPTIMISTIC, @@ -22,10 +21,6 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) -TemplateValvePublishAction = template_ns.class_( - "TemplateValvePublishAction", automation.Action, cg.Parented.template(TemplateValve) -) - TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") RESTORE_MODES = { "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, @@ -104,9 +99,10 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) -@automation.register_action( +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both write the position. +automation.register_apply_action( "valve.template.publish", - TemplateValvePublishAction, cv.Schema( { cv.GenerateID(): cv.use_id(TemplateValve), @@ -117,20 +113,10 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", valve.ValveOperation + ), + automation.ApplyCall("publish_state()"), ) -async def valve_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, cg.float_) - cg.add(var.set_position(template_)) - if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, cg.float_) - cg.add(var.set_position(template_)) - if current_operation_config := config.get(CONF_CURRENT_OPERATION): - template_ = await cg.templatable( - current_operation_config, args, valve.ValveOperation - ) - cg.add(var.set_current_operation(template_)) - return var diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h deleted file mode 100644 index ec9d784ab6..0000000000 --- a/esphome/components/template/valve/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "template_valve.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateValvePublishAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) - - void play(const Ts &...x) override { - if (this->position_.has_value()) - this->parent_->position = this->position_.value(x...); - if (this->current_operation_.has_value()) - this->parent_->current_operation = this->current_operation_.value(x...); - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index fd868b9709..81b13de7bf 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -475,6 +475,7 @@ select: lock: - platform: template + id: template_lock name: "Template Lock" lambda: |- if (id(template_some_binary_sensor).state) { @@ -483,8 +484,14 @@ lock: return LOCK_STATE_UNLOCKED; lock_action: - logger.log: lock_action + - lock.template.publish: + id: template_lock + state: LOCKED unlock_action: - logger.log: unlock_action + - lock.template.publish: + id: template_lock + state: !lambda "return LOCK_STATE_UNLOCKED;" open_action: - logger.log: open_action @@ -504,6 +511,14 @@ valve: - valve.template.publish: id: template_valve state: CLOSED + - valve.template.publish: + id: template_valve + position: 50% + current_operation: IDLE + - valve.template.publish: + id: template_valve + position: !lambda "return 0.25f;" + current_operation: !lambda "return VALVE_OPERATION_CLOSING;" stop_action: - logger.log: stop_action # Exercise valve.control with various field combinations so the From bd1efeae9498ca1526135e544127d1942b0f6db7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:20:29 +0100 Subject: [PATCH 261/316] [servo] Use register_apply_action for servo.write and servo.detach (#19531) --- esphome/components/servo/__init__.py | 34 ++++------------------------ esphome/components/servo/servo.h | 22 ------------------ tests/components/servo/common.yaml | 3 +++ 3 files changed, 7 insertions(+), 52 deletions(-) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 666c7dbcdd..74acaa3496 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,14 +13,10 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) -ServoWriteAction = servo_ns.class_("ServoWriteAction", automation.Action) -ServoDetachAction = servo_ns.class_("ServoDetachAction", automation.Action) CONF_AUTO_DETACH_TIME = "auto_detach_time" MULTI_CONF = True @@ -56,45 +52,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transition_length(config[CONF_TRANSITION_LENGTH])) -@automation.register_action( +automation.register_apply_action( "servo.write", - ServoWriteAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(Servo), cv.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "write", cg.float_), ) -async def servo_write_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "servo.detach", - ServoDetachAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Servo), } ), - synchronous=True, + automation.ApplyCall("detach()"), ) -async def servo_detach_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 156dab6dc1..ce29c783e2 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -51,25 +50,4 @@ class Servo final : public Component { }; }; -template class ServoWriteAction final : public Action { - public: - ServoWriteAction(Servo *servo) : servo_(servo) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->servo_->write(this->value_.value(x...)); } - - protected: - Servo *servo_; -}; - -template class ServoDetachAction final : public Action { - public: - ServoDetachAction(Servo *servo) : servo_(servo) {} - - void play(const Ts &...x) override { this->servo_->detach(); } - - protected: - Servo *servo_; -}; - } // namespace esphome::servo diff --git a/tests/components/servo/common.yaml b/tests/components/servo/common.yaml index 2cfa370c32..797d05f41f 100644 --- a/tests/components/servo/common.yaml +++ b/tests/components/servo/common.yaml @@ -4,6 +4,9 @@ esphome: - servo.write: id: test_servo level: -100.0% + - servo.write: + id: test_servo + level: !lambda return 0.5; - servo.detach: test_servo output: From 606525ed18e50586caef7bd9cc84fcf7b3a863d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:21:21 +0100 Subject: [PATCH 262/316] [output] Use register_apply_action for the output actions (#19512) --- esphome/components/output/__init__.py | 113 ++++++++++--------------- esphome/components/output/automation.h | 69 --------------- tests/components/output/common.yaml | 3 + 3 files changed, 48 insertions(+), 137 deletions(-) delete mode 100644 esphome/components/output/automation.h diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 10d5e5eb59..1b79403784 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,12 +14,26 @@ from esphome.const import ( CONF_POWER_SUPPLY, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "output" CONF_ZERO_MEANS_ZERO = "zero_means_zero" + +@dataclass +class OutputData: + power_scaling: bool = False + + +def _get_data() -> OutputData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = OutputData() + return CORE.data[DOMAIN] + + BINARY_OUTPUT_SCHEMA = cv.Schema( { cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), @@ -39,13 +55,6 @@ BinaryOutputPtr = BinaryOutput.operator("ptr") FloatOutput = output_ns.class_("FloatOutput", BinaryOutput) FloatOutputPtr = FloatOutput.operator("ptr") -# Actions -TurnOffAction = output_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = output_ns.class_("TurnOnAction", automation.Action) -SetLevelAction = output_ns.class_("SetLevelAction", automation.Action) -SetMinPowerAction = output_ns.class_("SetMinPowerAction", automation.Action) -SetMaxPowerAction = output_ns.class_("SetMaxPowerAction", automation.Action) - async def setup_output_platform_(obj, config): if CONF_INVERTED in config: @@ -85,81 +94,49 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +def _enable_power_scaling(config: ConfigType) -> ConfigType: + # set_min_power/set_max_power only exist with the define; to_code emits it from this fact. + _get_data().power_scaling = True + return config -@automation.register_action( - "output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("output.turn_on", "turn_on()"), + ("output.turn_off", "turn_off()"), +): + automation.register_apply_action( + _name, BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "output.set_level", - SetLevelAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(FloatOutput), cv.Required(CONF_LEVEL): cv.templatable(cv.percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_level", cg.float_), ) -async def output_set_level_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_level(template_)) - return var - -@automation.register_action( - "output.set_min_power", - SetMinPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_min_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) - cg.add(var.set_min_power(template_)) - return var - - -@automation.register_action( - "output.set_max_power", - SetMaxPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_max_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) - cg.add(var.set_max_power(template_)) - return var +for _name, _key, _target in ( + ("output.set_min_power", CONF_MIN_POWER, "set_min_power"), + ("output.set_max_power", CONF_MAX_POWER, "set_max_power"), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(FloatOutput), + cv.Required(_key): cv.templatable(cv.percentage), + } + ).add_extra(_enable_power_scaling), + automation.ApplyField(_key, _target, cg.float_), + ) async def to_code(config): cg.add_define("USE_OUTPUT") + if _get_data().power_scaling: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add_global(output_ns.using) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h deleted file mode 100644 index efe775ba57..0000000000 --- a/esphome/components/output/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/core/defines.h" -#include "esphome/components/output/binary_output.h" -#include "esphome/components/output/float_output.h" - -namespace esphome::output { - -template class TurnOffAction final : public Action { - public: - TurnOffAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_off(); } - - protected: - BinaryOutput *output_; -}; - -template class TurnOnAction final : public Action { - public: - TurnOnAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_on(); } - - protected: - BinaryOutput *output_; -}; - -template class SetLevelAction final : public Action { - public: - SetLevelAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_level(this->level_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -#ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction final : public Action { - public: - SetMinPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, min_power) - - void play(const Ts &...x) override { this->output_->set_min_power(this->min_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -template class SetMaxPowerAction final : public Action { - public: - SetMaxPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, max_power) - - void play(const Ts &...x) override { this->output_->set_max_power(this->max_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; -#endif // USE_OUTPUT_FLOAT_POWER_SCALING - -} // namespace esphome::output diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b..6448b3cfe6 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -6,6 +6,9 @@ esphome: - output.set_level: id: output_light_output_1 level: 50% + - output.set_level: + id: output_light_output_1 + level: !lambda return 0.25f; - output.set_min_power: id: output_light_output_1 min_power: 20% From 62b501049f69a7f3ba82296534e6a8dc52219236 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:21:39 +0100 Subject: [PATCH 263/316] [datetime] Use register_apply_action for date.set, time.set and datetime.set (#19513) --- esphome/components/datetime/__init__.py | 177 ++++++------------ esphome/components/datetime/date_entity.h | 14 -- esphome/components/datetime/datetime_entity.h | 14 -- esphome/components/datetime/time_entity.h | 14 -- tests/components/datetime/common.yaml | 40 ++++ 5 files changed, 100 insertions(+), 159 deletions(-) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index f8b6446006..3a33e5e004 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, time, web_server @@ -21,13 +23,13 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -40,11 +42,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -# Actions -DateSetAction = datetime_ns.class_("DateSetAction", automation.Action) -TimeSetAction = datetime_ns.class_("TimeSetAction", automation.Action) -DateTimeSetAction = datetime_ns.class_("DateTimeSetAction", automation.Action) - DateTimeStateTrigger = datetime_ns.class_( "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) ) @@ -181,118 +178,64 @@ async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) -@automation.register_action( - "datetime.date.set", - DateSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateEntity), - cv.Required(CONF_DATE): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=False) - ), - } - ), - synchronous=True, -) -async def datetime_date_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - date_config = config[CONF_DATE] - if cg.is_template(date_config): - template_ = await cg.templatable(date_config, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - else: - date_struct = cg.StructInitializer( - cg.ESPTime, - ("day_of_month", date_config[CONF_DAY]), - ("month", date_config[CONF_MONTH]), - ("year", date_config[CONF_YEAR]), +def _esptime_initializer( + members: tuple[tuple[str, str], ...], +) -> Callable[[ConfigType, ConfigType], str]: + def const_fn(config: ConfigType, value: ConfigType) -> str: + return str( + cg.StructInitializer( + cg.ESPTime, *((member, value[key]) for member, key in members) + ) ) - template_ = await cg.templatable(date_struct, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - return action_var + + return const_fn -@automation.register_action( - "datetime.time.set", - TimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(TimeEntity), - cv.Required(CONF_TIME): cv.Any( - cv.returning_lambda, cv.date_time(date=False, time=True) - ), - } +# ESPTime member order, so the designated initializer compiles. +_TIME_MEMBERS = (("second", CONF_SECOND), ("minute", CONF_MINUTE), ("hour", CONF_HOUR)) +_DATE_MEMBERS = (("day_of_month", CONF_DAY), ("month", CONF_MONTH), ("year", CONF_YEAR)) + +for _name, _entity, _key, _target, _date, _time, _members in ( + ( + "datetime.date.set", + DateEntity, + CONF_DATE, + "set_date", + True, + False, + _DATE_MEMBERS, ), - synchronous=True, -) -async def datetime_time_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - time_config = config[CONF_TIME] - if cg.is_template(time_config): - template_ = await cg.templatable(time_config, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - else: - time_struct = cg.StructInitializer( - cg.ESPTime, - ("second", time_config[CONF_SECOND]), - ("minute", time_config[CONF_MINUTE]), - ("hour", time_config[CONF_HOUR]), - ) - template_ = await cg.templatable(time_struct, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - return action_var - - -@automation.register_action( - "datetime.datetime.set", - DateTimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateTimeEntity), - cv.Required(CONF_DATETIME): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=True) - ), - }, + ( + "datetime.time.set", + TimeEntity, + CONF_TIME, + "set_time", + False, + True, + _TIME_MEMBERS, ), - synchronous=True, -) -async def datetime_datetime_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - datetime_config = config[CONF_DATETIME] - if cg.is_template(datetime_config): - template_ = await cg.templatable(datetime_config, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - else: - datetime_struct = cg.StructInitializer( - cg.ESPTime, - ("second", datetime_config[CONF_SECOND]), - ("minute", datetime_config[CONF_MINUTE]), - ("hour", datetime_config[CONF_HOUR]), - ("day_of_month", datetime_config[CONF_DAY]), - ("month", datetime_config[CONF_MONTH]), - ("year", datetime_config[CONF_YEAR]), - ) - template_ = await cg.templatable(datetime_struct, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - return action_var + ( + "datetime.datetime.set", + DateTimeEntity, + CONF_DATETIME, + "set_datetime", + True, + True, + _TIME_MEMBERS + _DATE_MEMBERS, + ), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(_entity), + cv.Required(_key): cv.Any( + cv.returning_lambda, cv.date_time(date=_date, time=_time) + ), + } + ), + automation.ApplyField( + _key, _target, cg.ESPTime, const_fn=_esptime_initializer(_members) + ), + call="make_call", + ) diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 93ce1411f8..9404d21512 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -98,20 +98,6 @@ class DateCall { inline DateCall DateEntity::make_call() { return DateCall(this); } -template class DateSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, date) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->date_.has_value()) { - call.set_date(this->date_.value(x...)); - } - call.perform(); - } -}; - } // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index fec620b5ba..57d305cb52 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -123,20 +123,6 @@ class DateTimeCall { inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } -template class DateTimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, datetime) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->datetime_.has_value()) { - call.set_datetime(this->datetime_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 736e26f4a7..fd31318ce5 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -100,20 +100,6 @@ class TimeCall { inline TimeCall TimeEntity::make_call() { return TimeCall(this); } -template class TimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, time) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->time_.has_value()) { - call.set_time(this->time_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index 4e26b68121..add7564529 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -1,3 +1,43 @@ +esphome: + on_boot: + then: + - datetime.date.set: + id: datetime_test_date + date: + year: 2021 + month: 1 + day: 1 + - datetime.date.set: + id: datetime_test_date + date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" + - datetime.time.set: + id: datetime_test_time + time: "12:34:56" + - datetime.time.set: + id: datetime_test_time + time: !lambda "return {.second = 56, .minute = 34, .hour = 12};" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: "2021-01-01 12:34:56" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: !lambda "return {.second = 56, .minute = 34, .hour = 12, .day_of_month = 1, .month = 1, .year = 2021};" + datetime: + - platform: template + name: Datetime Test Date + id: datetime_test_date + type: date + optimistic: true + - platform: template + name: Datetime Test Time + id: datetime_test_time + type: time + optimistic: true + - platform: template + name: Datetime Test DateTime + id: datetime_test_datetime + type: datetime + optimistic: true time: From aaed14d0180b69af0c93b872688b38cd3ad9ed50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:22:20 +0100 Subject: [PATCH 264/316] [media_player] Use register_apply_action for the media player actions (#19514) --- esphome/components/media_player/__init__.py | 96 +++++++------------ esphome/components/media_player/automation.h | 70 -------------- tests/components/media_player/common.yaml | 22 +++-- .../media_player/test.esp32-idf.yaml | 9 ++ 4 files changed, 58 insertions(+), 139 deletions(-) create mode 100644 tests/components/media_player/test.esp32-idf.yaml diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index aa1e88dca9..ed3eaf4853 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -152,7 +152,7 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_ANNOUNCEMENT = "on_announcement" CONF_MEDIA_URL = "media_url" -# Command actions that all share the same schema and codegen handler +# Command actions that all share the same schema and only differ in the command sent _COMMAND_ACTIONS = [ "play", "pause", @@ -201,16 +201,7 @@ _STATE_CONDITIONS = [ "muted", ] -# Special action classes with custom schemas/handlers -PlayMediaAction = media_player_ns.class_( - "PlayMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -EnqueueMediaAction = media_player_ns.class_( - "EnqueueMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -VolumeSetAction = media_player_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(MediaPlayer) -) +MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) _CALLBACK_AUTOMATIONS = ( @@ -329,59 +320,50 @@ _MEDIA_URL_ACTION_SCHEMA = cv.maybe_simple_value( ) -async def _media_action_handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_url = await cg.templatable(config[CONF_MEDIA_URL], args, cg.std_string) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_media_url(media_url)) - cg.add(var.set_announcement(announcement)) - return var +_ANNOUNCEMENT_FIELD = automation.ApplyField( + CONF_ANNOUNCEMENT, "set_announcement", cg.bool_ +) +_MEDIA_URL_FIELD = automation.ApplyField(CONF_MEDIA_URL, "set_media_url", cg.std_string) -automation.register_action( +def _set_command(command_name: str) -> automation.ApplyCall: + command = getattr( + MediaPlayerCommand, f"MEDIA_PLAYER_COMMAND_{command_name.upper()}" + ) + return automation.ApplyCall(f"set_command({command})") + + +automation.register_apply_action( "media_player.play_media", - PlayMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) -automation.register_action( +automation.register_apply_action( "media_player.enqueue", - EnqueueMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _set_command("enqueue"), + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) + +for _action_name in _COMMAND_ACTIONS: + automation.register_apply_action( + f"media_player.{_action_name}", + MEDIA_PLAYER_ACTION_SCHEMA, + _set_command(_action_name), + _ANNOUNCEMENT_FIELD, + call="make_call", + ) def _snake_to_camel(name): return "".join(word.capitalize() for word in name.split("_")) -def _register_command_actions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_announcement(announcement)) - return var - - for action_name in _COMMAND_ACTIONS: - class_name = f"{_snake_to_camel(action_name)}Action" - action_class = media_player_ns.class_( - class_name, automation.Action, cg.Parented.template(MediaPlayer) - ) - automation.register_action( - f"media_player.{action_name}", - action_class, - MEDIA_PLAYER_ACTION_SCHEMA, - synchronous=True, - )(handler) - - -_register_command_actions() - - def _register_state_conditions(): async def handler(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -401,9 +383,8 @@ def _register_state_conditions(): _register_state_conditions() -@automation.register_action( +automation.register_apply_action( "media_player.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(MediaPlayer), @@ -411,14 +392,9 @@ _register_state_conditions() }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), + call="make_call", ) -async def media_player_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 899acfefdf..648e9eb29f 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -5,76 +5,6 @@ namespace esphome::media_player { -template -class MediaPlayerCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, announcement); - void play(const Ts &...x) override { - this->parent_->make_call().set_command(Command).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayAction = MediaPlayerCommandAction; -template -using PauseAction = MediaPlayerCommandAction; -template -using StopAction = MediaPlayerCommandAction; -template -using ToggleAction = MediaPlayerCommandAction; -template -using VolumeUpAction = MediaPlayerCommandAction; -template -using VolumeDownAction = MediaPlayerCommandAction; -template -using TurnOnAction = MediaPlayerCommandAction; -template -using TurnOffAction = MediaPlayerCommandAction; -template -using NextAction = MediaPlayerCommandAction; -template -using PreviousAction = MediaPlayerCommandAction; -template -using MuteAction = MediaPlayerCommandAction; -template -using UnmuteAction = MediaPlayerCommandAction; -template -using RepeatOffAction = MediaPlayerCommandAction; -template -using RepeatOneAction = MediaPlayerCommandAction; -template -using RepeatAllAction = MediaPlayerCommandAction; -template -using ShuffleAction = MediaPlayerCommandAction; -template -using UnshuffleAction = MediaPlayerCommandAction; -template -using GroupJoinAction = MediaPlayerCommandAction; -template -using ClearPlaylistAction = MediaPlayerCommandAction; - -template -class MediaPlayerMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, media_url) - TEMPLATABLE_VALUE(bool, announcement) - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - if constexpr (Command != MediaPlayerCommand::MEDIA_PLAYER_COMMAND_PLAY) - call.set_command(Command); - call.set_media_url(this->media_url_.value(x...)).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayMediaAction = MediaPlayerMediaAction; -template -using EnqueueMediaAction = MediaPlayerMediaAction; - -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } -}; - /// Callback forwarder that triggers an Automation<> on any state change. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. struct StateAnyForwarder { diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index 88d04d0ff0..dd6773864d 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -1,28 +1,30 @@ -i2s_audio: - i2s_lrclk_pin: 13 - i2s_bclk_pin: 14 - i2s_mclk_pin: 15 - speaker: - platform: i2s_audio id: test_speaker - i2s_dout_pin: 18 + i2s_audio_id: i2s_audio_bus + i2s_dout_pin: ${i2s_dout_pin} dac_type: external media_player: - platform: speaker + id: test_media_player name: None - speaker: test_speaker + announcement_pipeline: + speaker: test_speaker + format: NONE on_state: - media_player.play: - media_player.play_media: http://localhost/media.mp3 - - media_player.play_media: !lambda 'return "http://localhost/media.mp3";' + - media_player.play_media: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; on_idle: - media_player.pause: on_play: - media_player.stop: - media_player.stop: announcement: true + - media_player.stop: + announcement: !lambda 'return id(test_media_player).is_muted();' on_announcement: - media_player.play: on_turn_on: @@ -61,8 +63,10 @@ media_player: - media_player.volume_up: - media_player.volume_down: - media_player.volume_set: 50% + - media_player.volume_set: !lambda 'return id(test_media_player).volume / 2;' - media_player.enqueue: http://localhost/media.mp3 - - media_player.enqueue: !lambda 'return "http://localhost/media.mp3";' + - media_player.enqueue: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; - media_player.enqueue: media_url: http://localhost/media.mp3 announcement: true diff --git a/tests/components/media_player/test.esp32-idf.yaml b/tests/components/media_player/test.esp32-idf.yaml new file mode 100644 index 0000000000..3e55bfb742 --- /dev/null +++ b/tests/components/media_player/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_dout_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + media_player: !include common.yaml + +wifi: + ap: From 7df946b66575f56c27ba7b10c0b17225dfff4065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:22:44 +0100 Subject: [PATCH 265/316] [ledc] Use register_apply_action for the set_frequency action (#19515) --- esphome/components/ledc/ledc_output.h | 15 --------------- esphome/components/ledc/output.py | 19 ++----------------- tests/components/ledc/common.yaml | 3 +++ 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b0a243f2e4..de63325455 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #include @@ -43,20 +42,6 @@ class LEDCOutput final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LEDCOutput *parent_; -}; - } // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index e5e7c3dcbe..bb7f8de3fe 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -12,8 +12,6 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp32"] @@ -45,7 +43,6 @@ def validate_frequency(value: Any) -> float: ledc_ns = cg.esphome_ns.namespace("ledc") LEDCOutput = ledc_ns.class_("LEDCOutput", output.FloatOutput, cg.Component) -SetFrequencyAction = ledc_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -77,25 +74,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_phase_angle(config[CONF_PHASE_ANGLE])) -@automation.register_action( +automation.register_apply_action( "output.ledc.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LEDCOutput), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def ledc_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/tests/components/ledc/common.yaml b/tests/components/ledc/common.yaml index 70352b4519..52bef63fbb 100644 --- a/tests/components/ledc/common.yaml +++ b/tests/components/ledc/common.yaml @@ -4,6 +4,9 @@ esphome: - output.ledc.set_frequency: id: test_ledc frequency: 100Hz + - output.ledc.set_frequency: + id: test_ledc + frequency: !lambda return 200.0f; output: - platform: ledc From 802f2b9f24f9ab25841ed295932a8db27db1bef2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:20 +0100 Subject: [PATCH 266/316] [libretiny_pwm] Use register_apply_action for the set_frequency action (#19518) --- .../components/libretiny_pwm/libretiny_pwm.h | 15 --------------- esphome/components/libretiny_pwm/output.py | 19 ++----------------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f0ea0228b7..0b3d55f6d2 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #ifdef USE_LIBRETINY @@ -34,20 +33,6 @@ class LibreTinyPWM final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LibreTinyPWM *parent_; -}; - } // namespace esphome::libretiny_pwm #endif diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 716ccfad2b..e8be88df60 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,15 +3,12 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] libretinypwm_ns = cg.esphome_ns.namespace("libretiny_pwm") LibreTinyPWM = libretinypwm_ns.class_("LibreTinyPWM", output.FloatOutput, cg.Component) -SetFrequencyAction = libretinypwm_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -32,25 +29,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.libretiny_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LibreTinyPWM), cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def libretiny_pwm_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var From 07c8cfdde3039b76152d51403e9859f19f015590 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:22 +0100 Subject: [PATCH 267/316] [cc1101] Use register_apply_action for the setter actions (#19472) --- esphome/components/cc1101/__init__.py | 114 ++++++++------------------ esphome/components/cc1101/cc1101.h | 79 ------------------ 2 files changed, 32 insertions(+), 161 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 01e3ed0cd5..dbafae4ad0 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -424,91 +424,41 @@ async def send_packet_action_to_code(config, action_id, template_arg, args): return var -# Setter action definitions: (setter_name, validator, template_type, enum_map) -_SETTER_ACTIONS = [ - ( - "set_frequency", - cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), - float, - None, - ), - ("set_output_power", cv.float_range(min=-30.0, max=11.0), float, None), - ("set_modulation_type", cv.enum(MODULATION, upper=False), Modulation, MODULATION), - ("set_symbol_rate", cv.float_range(min=600, max=500000), float, None), - ( - "set_rx_attenuation", - cv.enum(RX_ATTENUATION, upper=False), - RxAttenuation, - RX_ATTENUATION, - ), - ("set_dc_blocking_filter", cv.boolean, bool, None), - ("set_manchester", cv.boolean, bool, None), - ( - "set_filter_bandwidth", - cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), - float, - None, - ), - ( - "set_fsk_deviation", - cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), - float, - None, - ), - ("set_msk_deviation", cv.int_range(min=1, max=8), cg.uint8, None), - ("set_channel", cv.uint8_t, cg.uint8, None), - ( - "set_channel_spacing", - cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), - float, - None, - ), - ( - "set_if_frequency", - cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), - float, - None, - ), -] +_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()} + +# Each key has a set_ action validated exactly like the config option. +_SETTER_ACTIONS = ( + (CONF_FREQUENCY, cg.float_), + (CONF_OUTPUT_POWER, cg.float_), + (CONF_MODULATION_TYPE, Modulation), + (CONF_SYMBOL_RATE, cg.float_), + (CONF_RX_ATTENUATION, RxAttenuation), + (CONF_DC_BLOCKING_FILTER, cg.bool_), + (CONF_MANCHESTER, cg.bool_), + (CONF_FILTER_BANDWIDTH, cg.float_), + (CONF_FSK_DEVIATION, cg.float_), + (CONF_MSK_DEVIATION, cg.uint8), + (CONF_CHANNEL, cg.uint8), + (CONF_CHANNEL_SPACING, cg.float_), + (CONF_IF_FREQUENCY, cg.float_), +) -def _register_setter_actions(): - for setter_name, validator, templ_type, enum_map in _SETTER_ACTIONS: - class_name = ( - "".join(word.capitalize() for word in setter_name.split("_")) + "Action" +def _register_setter_actions() -> None: + for conf_key, value_type in _SETTER_ACTIONS: + automation.register_apply_action( + f"cc1101.set_{conf_key}", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_VALUE): cv.templatable( + _CONFIG_VALIDATORS[conf_key] + ), + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, f"set_{conf_key}", value_type), ) - action_cls = ns.class_( - class_name, automation.Action, cg.Parented.template(CC1101Component) - ) - schema = cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_VALUE): cv.templatable(validator), - }, - key=CONF_VALUE, - ) - - async def _setter_action_to_code( - config, - action_id, - template_arg, - args, - _setter=setter_name, - _type=templ_type, - _map=enum_map, - ): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_VALUE] - if _map and not cg.is_template(data): - data = _map[data] - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - return var - - automation.register_action( - f"cc1101.{setter_name}", action_cls, schema, synchronous=True - )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 79bfc9cb33..1f7a55fbf1 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -174,83 +174,4 @@ template class SendPacketAction final : public Action, pu size_t data_static_len_{0}; }; -template class SetSymbolRateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, symbol_rate) - void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } -}; - -template class SetFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, frequency) - void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } -}; - -template class SetOutputPowerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, output_power) - void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } -}; - -template class SetModulationTypeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Modulation, modulation_type) - void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } -}; - -template class SetRxAttenuationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) - void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } -}; - -template -class SetDcBlockingFilterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, dc_blocking_filter) - void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } -}; - -template class SetManchesterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, manchester) - void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } -}; - -template class SetFilterBandwidthAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, filter_bandwidth) - void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } -}; - -template class SetFskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, fsk_deviation) - void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } -}; - -template class SetMskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, msk_deviation) - void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } -}; - -template class SetChannelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } -}; - -template class SetChannelSpacingAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, channel_spacing) - void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } -}; - -template class SetIfFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, if_frequency) - void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } -}; - } // namespace esphome::cc1101 From f58fe2e3ead92f55f67d860fb8a3c603ae35f0de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:30 +0100 Subject: [PATCH 268/316] [select] Use register_apply_action for the set, set_index and operation actions (#19511) --- esphome/components/select/__init__.py | 128 +++++------------- esphome/components/select/automation.h | 49 ------- tests/components/select/common.yaml | 43 ++++++ tests/components/select/test.esp32-idf.yaml | 2 + tests/components/select/test.esp8266-ard.yaml | 2 + 5 files changed, 78 insertions(+), 146 deletions(-) create mode 100644 tests/components/select/common.yaml create mode 100644 tests/components/select/test.esp32-idf.yaml create mode 100644 tests/components/select/test.esp8266-ard.yaml diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index f561c030a4..2db4c8cc34 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -40,11 +40,6 @@ SelectStateTrigger = select_ns.class_( automation.Trigger.template(cg.StringRef, cg.size_t), ) -# Actions -SelectSetAction = select_ns.class_("SelectSetAction", automation.Action) -SelectSetIndexAction = select_ns.class_("SelectSetIndexAction", automation.Action) -SelectOperationAction = select_ns.class_("SelectOperationAction", automation.Action) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -140,40 +135,27 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "select.set", - SelectSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_OPTION, "set_option", cg.std_string), + call="make_call", ) -async def select_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_OPTION], args, cg.std_string) - cg.add(var.set_option(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "select.set_index", - SelectSetIndexAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_INDEX): cv.templatable(cv.positive_int), } ), - synchronous=True, + automation.ApplyField(CONF_INDEX, "set_index", cg.size_t), + call="make_call", ) -async def select_set_index_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.size_t) - cg.add(var.set_index(template_)) - return var @automation.register_condition( @@ -211,9 +193,8 @@ async def select_is_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren, arg) -@automation.register_action( +automation.register_apply_action( "select.operation", - SelectOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -222,75 +203,28 @@ async def select_is_to_code(config, condition_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", SelectOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -@automation.register_action( - "select.next", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="NEXT"): cv.one_of("NEXT", upper=True), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.previous", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="PREVIOUS"): cv.one_of( - "PREVIOUS", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.first", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="FIRST"): cv.one_of("FIRST", upper=True), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.last", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="LAST"): cv.one_of("LAST", upper=True), - } - ) - ), - synchronous=True, -) -async def select_operation_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - op_ = await cg.templatable(operation, args, SelectOperation) - cg.add(var.set_operation(op_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - SELECT_OPERATION_OPTIONS[mode], args, SelectOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var + +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("select.next", "NEXT", True), + ("select.previous", "PREVIOUS", True), + ("select.first", "FIRST", False), + ("select.last", "LAST", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({SELECT_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 8e5da893ad..c2dc752ca5 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -17,55 +17,6 @@ class SelectStateTrigger final : public Trigger { Select *parent_; }; -template class SelectSetAction final : public Action { - public: - explicit SelectSetAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(std::string, option) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_option(this->option_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectSetIndexAction final : public Action { - public: - explicit SelectSetIndexAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(size_t, index) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_index(this->index_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectOperationAction final : public Action { - public: - explicit SelectOperationAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(bool, cycle) - TEMPLATABLE_VALUE(SelectOperation, operation) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Select *select_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/tests/components/select/common.yaml b/tests/components/select/common.yaml new file mode 100644 index 0000000000..f20c024cb8 --- /dev/null +++ b/tests/components/select/common.yaml @@ -0,0 +1,43 @@ +select: + - platform: template + name: "Test Select" + id: select_test_select + optimistic: true + options: + - one + - two + - three + initial_option: two + +button: + - platform: template + name: "Test Select Actions" + on_press: + - select.set: + id: select_test_select + option: three + - select.set: + id: select_test_select + option: !lambda |- + std::string option = "one"; + return option; + - select.set_index: + id: select_test_select + index: 1 + - select.set_index: + id: select_test_select + index: !lambda return 2; + - select.next: select_test_select + - select.previous: + id: select_test_select + cycle: false + - select.first: select_test_select + - select.last: select_test_select + - select.operation: + id: select_test_select + operation: next + cycle: false + - select.operation: + id: select_test_select + operation: !lambda return SELECT_OP_PREVIOUS; + cycle: !lambda return true; diff --git a/tests/components/select/test.esp32-idf.yaml b/tests/components/select/test.esp32-idf.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/select/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/select/test.esp8266-ard.yaml b/tests/components/select/test.esp8266-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/select/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml From 2a26d5ab62e493310d9dae2d9516b154202e8970 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:55 +0100 Subject: [PATCH 269/316] [number] Use register_apply_action for the set and operation actions (#19510) --- esphome/components/number/__init__.py | 119 ++++++------------------- esphome/components/number/automation.h | 34 ------- tests/components/number/common.yaml | 27 ++++++ 3 files changed, 54 insertions(+), 126 deletions(-) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index fc0893323b..afa2fac483 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -163,10 +163,6 @@ ValueRangeTrigger = number_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -# Actions -NumberSetAction = number_ns.class_("NumberSetAction", automation.Action) -NumberOperationAction = number_ns.class_("NumberOperationAction", automation.Action) - # Conditions NumberInRangeCondition = number_ns.class_( "NumberInRangeCondition", automation.Condition @@ -366,85 +362,40 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "number.set", - NumberSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), + call="make_call", ) -async def number_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("number.increment", "INCREMENT", True), + ("number.decrement", "DECREMENT", True), + ("number.to_min", "TO_MIN", False), + ("number.to_max", "TO_MAX", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({NUMBER_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) -@automation.register_action( - "number.increment", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="INCREMENT"): cv.one_of( - "INCREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.decrement", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="DECREMENT"): cv.one_of( - "DECREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_min", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MIN"): cv.one_of( - "TO_MIN", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_max", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MAX"): cv.one_of( - "TO_MAX", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( +automation.register_apply_action( "number.operation", - NumberOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -453,23 +404,7 @@ async def number_set_to_code(config, action_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", NumberOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -async def number_to_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - to_ = await cg.templatable(operation, args, NumberOperation) - cg.add(var.set_operation(to_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - NUMBER_OPERATION_OPTIONS[mode], args, NumberOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 4efcfd30d8..f870acf8dd 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -13,40 +13,6 @@ class NumberStateTrigger final : public Trigger { } }; -template class NumberSetAction final : public Action { - public: - NumberSetAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Number *number_; -}; - -template class NumberOperationAction final : public Action { - public: - explicit NumberOperationAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(NumberOperation, operation) - TEMPLATABLE_VALUE(bool, cycle) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Number *number_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfed..3199dd90f8 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -11,3 +11,30 @@ sensor: - platform: number name: "Test Number Value" source_id: number_test_number + +button: + - platform: template + name: "Test Number Actions" + on_press: + - number.set: + id: number_test_number + value: 42 + - number.set: + id: number_test_number + value: !lambda |- + float next = id(number_test_number).state + 1; + return next; + - number.increment: number_test_number + - number.decrement: + id: number_test_number + cycle: false + - number.to_min: number_test_number + - number.to_max: number_test_number + - number.operation: + id: number_test_number + operation: increment + cycle: false + - number.operation: + id: number_test_number + operation: !lambda return NUMBER_OP_DECREMENT; + cycle: !lambda return true; From bd91d3ff239726578675e53ee3edd0eda8a495fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:12 +0100 Subject: [PATCH 270/316] [display] Use register_apply_action for display.page.show_next and show_previous (#19519) --- esphome/components/display/__init__.py | 43 ++++++++------------------ esphome/components/display/display.h | 18 ----------- tests/components/mipi_spi/common.yaml | 11 +++++++ 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 7a66da11f2..b664579be9 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,12 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayPageShowNextAction = display_ns.class_( - "DisplayPageShowNextAction", automation.Action -) -DisplayPageShowPrevAction = display_ns.class_( - "DisplayPageShowPrevAction", automation.Action -) DisplayIsDisplayingPageCondition = display_ns.class_( "DisplayIsDisplayingPageCondition", automation.Condition ) @@ -288,34 +282,23 @@ async def display_page_show_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +# The id is the display itself, so it cannot be a lambda; a plain use_id rejects one clearly. +DISPLAY_PAGE_CYCLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(Display), + } +) + +automation.register_apply_action( "display.page.show_next", - DisplayPageShowNextAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_next_page()"), ) -async def display_page_show_next_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( +automation.register_apply_action( "display.page.show_previous", - DisplayPageShowPrevAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_prev_page()"), ) -async def display_page_show_previous_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition( diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index c138972149..af5a7bc132 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -848,24 +848,6 @@ template class DisplayPageShowAction final : public Action class DisplayPageShowNextAction final : public Action { - public: - DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_next_page(); } - - Display *buffer_; -}; - -template class DisplayPageShowPrevAction final : public Action { - public: - DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_prev_page(); } - - Display *buffer_; -}; - template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index a867b726ed..6bdee9e54a 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -1,3 +1,10 @@ +esphome: + on_boot: + then: + - display.page.show_next: page_display + - display.page.show_previous: + id: page_display + display: - platform: mipi_spi spi_16: true @@ -32,7 +39,11 @@ display: bus_mode: single - platform: mipi_spi + id: page_display model: WAVESHARE-1.83-V2 + pages: + - lambda: it.rectangle(0, 0, 10, 10); + - lambda: it.rectangle(10, 10, 10, 10); dc_pin: allow_other_uses: true number: ${dc_pin} From 4efeee245c025577c9a5442b6fb72247930fbac8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:25 +0100 Subject: [PATCH 271/316] [text] Use register_apply_action for text.set (#19509) --- esphome/components/text/__init__.py | 24 +++++------------------- esphome/components/text/automation.h | 15 --------------- tests/components/text/common.yaml | 11 +++++++++++ 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index e010e2c292..2f82f19ff5 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,13 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] @@ -34,9 +34,6 @@ TextStateTrigger = text_ns.class_( "TextStateTrigger", automation.Trigger.template(cg.std_string) ) -# Actions -TextSetAction = text_ns.class_("TextSetAction", automation.Action) - # Conditions TextMode = text_ns.enum("TextMode") @@ -160,24 +157,13 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "text.set", - TextSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.std_string), + call="make_call", ) -async def text_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index 916d86340d..bd6fb3cd23 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -13,19 +13,4 @@ class TextStateTrigger final : public Trigger { } }; -template class TextSetAction final : public Action { - public: - explicit TextSetAction(Text *text) : text_(text) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { - auto call = this->text_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Text *text_; -}; - } // namespace esphome::text diff --git a/tests/components/text/common.yaml b/tests/components/text/common.yaml index 561d17143f..3110b60fa6 100644 --- a/tests/components/text/common.yaml +++ b/tests/components/text/common.yaml @@ -28,3 +28,14 @@ text_sensor: - platform: text name: "Test Text State" source_id: test_text + +button: + - platform: template + name: Set Text + on_press: + - text.set: + id: test_text + value: Hello + - text.set: + id: test_text + value: !lambda return id(test_text).state + "!"; From c275365258b87d0cb17cd9f3a718d1f961f6fcb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:44 +0100 Subject: [PATCH 272/316] [rp2040_pwm] Use register_apply_action for the set_frequency action (#19517) --- esphome/components/rp2040_pwm/output.py | 19 ++----------------- esphome/components/rp2040_pwm/rp2040_pwm.h | 14 -------------- tests/components/rp2040_pwm/common.yaml | 10 ++++++++++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a0344e8054..49b10f7d20 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -13,7 +11,6 @@ DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( @@ -36,25 +33,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.rp2040_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RP2040PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def rp2040_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 8263113168..7808e2af01 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -3,7 +3,6 @@ #ifdef USE_RP2 #include "esphome/components/output/float_output.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -39,19 +38,6 @@ class RP2040PWM final : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - RP2040PWM *parent_; -}; - } // namespace esphome::rp2040_pwm #endif // USE_RP2 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afb..b857a7e76f 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_1 + frequency: 100Hz + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_2 + frequency: !lambda return 200.0f; + output: - platform: rp2040_pwm id: rp2040_pwm_light_output_1 From 6a97172867ddd10bdb7195948274a4703aa5c392 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:56 +0100 Subject: [PATCH 273/316] [stepper] Use register_apply_action for the setter actions (#19470) --- esphome/components/stepper/__init__.py | 142 ++++++++----------------- esphome/components/stepper/stepper.h | 71 ------------- tests/components/stepper/common.yaml | 9 ++ 3 files changed, 56 insertions(+), 166 deletions(-) diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8e80187662..017fd95cd8 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv @@ -11,18 +14,13 @@ from esphome.const import ( CONF_TARGET, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import SafeExpType IS_PLATFORM_COMPONENT = True stepper_ns = cg.esphome_ns.namespace("stepper") Stepper = stepper_ns.class_("Stepper") -SetTargetAction = stepper_ns.class_("SetTargetAction", automation.Action) -ReportPositionAction = stepper_ns.class_("ReportPositionAction", automation.Action) -SetSpeedAction = stepper_ns.class_("SetSpeedAction", automation.Action) -SetAccelerationAction = stepper_ns.class_("SetAccelerationAction", automation.Action) -SetDecelerationAction = stepper_ns.class_("SetDecelerationAction", automation.Action) - def validate_acceleration(value): value = cv.string(value) @@ -90,99 +88,53 @@ async def register_stepper(var, config): await setup_stepper_core_(var, config) -@automation.register_action( - "stepper.set_target", - SetTargetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_TARGET): cv.templatable(cv.int_), - } - ), - synchronous=True, +def _register_stepper_action( + name: str, + key: str, + validator: Callable[[Any], Any], + target: str, + type_: SafeExpType, + *extra: automation.ApplyCall, +) -> None: + automation.register_apply_action( + f"stepper.{name}", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Stepper), + cv.Required(key): cv.templatable(validator), + } + ), + automation.ApplyField(key, target, type_), + *extra, + ) + + +_register_stepper_action("set_target", CONF_TARGET, cv.int_, "set_target", cg.int32) +_register_stepper_action( + "report_position", CONF_POSITION, cv.int_, "report_position", cg.int32 ) -async def stepper_set_target_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TARGET], args, cg.int32) - cg.add(var.set_target(template_)) - return var - - -@automation.register_action( - "stepper.report_position", - ReportPositionAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_POSITION): cv.templatable(cv.int_), - } - ), - synchronous=True, +_register_stepper_action( + "set_speed", + CONF_SPEED, + validate_speed, + "set_max_speed", + cg.float_, + automation.ApplyCall("on_update_speed()"), ) -async def stepper_report_position_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_POSITION], args, cg.int32) - cg.add(var.set_position(template_)) - return var - - -@automation.register_action( - "stepper.set_speed", - SetSpeedAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_SPEED): cv.templatable(validate_speed), - } - ), - synchronous=True, +_register_stepper_action( + "set_acceleration", + CONF_ACCELERATION, + validate_acceleration, + "set_acceleration", + cg.float_, ) -async def stepper_set_speed_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SPEED], args, cg.float_) - cg.add(var.set_speed(template_)) - return var - - -@automation.register_action( - "stepper.set_acceleration", - SetAccelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_ACCELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, +_register_stepper_action( + "set_deceleration", + CONF_DECELERATION, + validate_acceleration, + "set_deceleration", + cg.float_, ) -async def stepper_set_acceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_ACCELERATION], args, cg.float_) - cg.add(var.set_acceleration(template_)) - return var - - -@automation.register_action( - "stepper.set_deceleration", - SetDecelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_DECELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, -) -async def stepper_set_deceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DECELERATION], args, cg.float_) - cg.add(var.set_deceleration(template_)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 06ef3bab37..8a8fbcd896 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" namespace esphome::stepper { @@ -37,74 +36,4 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction final : public Action { - public: - explicit SetTargetAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, target) - - void play(const Ts &...x) override { this->parent_->set_target(this->target_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class ReportPositionAction final : public Action { - public: - explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, position) - - void play(const Ts &...x) override { this->parent_->report_position(this->position_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class SetSpeedAction final : public Action { - public: - explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, speed); - - void play(const Ts &...x) override { - float speed = this->speed_.value(x...); - this->parent_->set_max_speed(speed); - this->parent_->on_update_speed(); - } - - protected: - Stepper *parent_; -}; - -template class SetAccelerationAction final : public Action { - public: - explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, acceleration); - - void play(const Ts &...x) override { - float acceleration = this->acceleration_.value(x...); - this->parent_->set_acceleration(acceleration); - } - - protected: - Stepper *parent_; -}; - -template class SetDecelerationAction final : public Action { - public: - explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, deceleration); - - void play(const Ts &...x) override { - float deceleration = this->deceleration_.value(x...); - this->parent_->set_deceleration(deceleration); - } - - protected: - Stepper *parent_; -}; - } // namespace esphome::stepper diff --git a/tests/components/stepper/common.yaml b/tests/components/stepper/common.yaml index fcf5759618..ba70fb7979 100644 --- a/tests/components/stepper/common.yaml +++ b/tests/components/stepper/common.yaml @@ -25,3 +25,12 @@ switch: - stepper.report_position: id: test_stepper position: 0 + - stepper.set_speed: + id: test_stepper + speed: 300 steps/s + - stepper.set_acceleration: + id: test_stepper + acceleration: !lambda return 150.0f; + - stepper.set_deceleration: + id: test_stepper + deceleration: 250 steps/s^2 From 22efbd9778a446e3324f136bdaa9bf2ebc2f45e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:04 +0100 Subject: [PATCH 274/316] [esp32_ble_tracker] Use register_apply_action for start_scan and stop_scan (#19520) --- .../components/esp32_ble_tracker/__init__.py | 40 +++---------------- .../components/esp32_ble_tracker/automation.h | 24 ----------- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 +++ 3 files changed, 12 insertions(+), 58 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 906144e5fd..205acc55a5 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -39,8 +39,7 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType @@ -116,12 +115,6 @@ BLEEndOfScanTrigger = esp32_ble_tracker_ns.class_( "BLEEndOfScanTrigger", automation.Trigger.template() ) # Actions -ESP32BLEStartScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStartScanAction", automation.Action -) -ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStopScanAction", automation.Action -) def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: @@ -469,23 +462,12 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.start_scan", - ESP32BLEStartScanAction, ESP32_BLE_START_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_CONTINUOUS, "set_scan_continuous", cg.bool_), + automation.ApplyCall("start_scan_if_idle()"), ) -async def esp32_ble_tracker_start_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) - cg.add(var.set_continuous(template_)) - return var ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( @@ -497,21 +479,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.stop_scan", - ESP32BLEStopScanAction, ESP32_BLE_STOP_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def esp32_ble_tracker_stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def register_ble_device( diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 541b63b2fd..9b4eb2c3b3 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -94,30 +94,6 @@ class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction final : public Action { - public: - ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(bool, continuous) - void play(const Ts &...x) override { - this->parent_->set_scan_continuous(this->continuous_.value(x...)); - // Only call start_scan() if scanner is IDLE - // For other states (STARTING, RUNNING, STOPPING, FAILED), the normal state - // machine flow will eventually transition back to IDLE, at which point - // loop() will see scan_continuous_ and restart scanning if it is true. - if (this->parent_->get_scanner_state() == ScannerState::IDLE) { - this->parent_->start_scan(); - } - } - - protected: - ESP32BLETracker *parent_; -}; - -template class ESP32BLEStopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::esp32_ble_tracker #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index aa470983df..6b71480ed3 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -218,6 +218,12 @@ class ESP32BLETracker final : public Component, #endif void start_scan(); + // For the start_scan action: in any other state the state machine returns to IDLE on its own + // and loop() restarts scanning when scan_continuous_ is set, so only an idle scanner starts here. + void start_scan_if_idle() { + if (this->scanner_state_ == ScannerState::IDLE) + this->start_scan(); + } void stop_scan(); void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); From a47271b1f1c03a818251dd7fafb181cb81bcb9a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:33 +0100 Subject: [PATCH 275/316] [fan] Use register_apply_action for fan.turn_on (#19473) --- esphome/components/fan/__init__.py | 55 ++++------------------------- esphome/components/fan/automation.h | 27 -------------- tests/components/fan/common.yaml | 2 +- 3 files changed, 7 insertions(+), 77 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 3949f16d2e..d53357b7c1 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -31,19 +31,17 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) -FanCall = fan_ns.class_("FanCall") FanDirection = fan_ns.enum("FanDirection", is_class=True) FAN_DIRECTION_ENUM = { @@ -63,7 +61,6 @@ RESTORE_MODES = { } # Actions -TurnOnAction = fan_ns.class_("TurnOnAction", automation.Action) TurnOffAction = fan_ns.class_("TurnOffAction", automation.Action) ToggleAction = fan_ns.class_("ToggleAction", automation.Action) CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) @@ -332,9 +329,8 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_apply_action( "fan.turn_on", - TurnOnAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Fan), @@ -345,50 +341,11 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): ), } ), - synchronous=True, + automation.ApplyField(CONF_OSCILLATING, "set_oscillating", cg.bool_), + automation.ApplyField(CONF_SPEED, "set_speed", cg.int_), + automation.ApplyField(CONF_DIRECTION, "set_direction", FanDirection), + call="turn_on", ) -async def fan_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_OSCILLATING, "set_oscillating", cg.bool_), - (CONF_SPEED, "set_speed", cg.int_), - (CONF_DIRECTION, "set_direction", FanDirection), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (FanCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @automation.register_action( diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index cbd994e749..f62a2dd8cd 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,33 +6,6 @@ namespace esphome::fan { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `speed: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction final : public Action { - public: - using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); - TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->state_->turn_on(); - this->apply_(call, x...); - call.perform(); - } - - Fan *state_; - ApplyFn apply_; -}; - template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 76508f391e..3bef83cc42 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -67,7 +67,7 @@ binary_sensor: return false; # Exercise fan.turn_on with various field combinations so the -# TurnOnAction codegen paths get build coverage. +# register_apply_action codegen paths get build coverage. button: - platform: template name: "Fan Speed Only" From 281c54bcb88195d5e0bc15ce92bf85baec206dd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:42 +0100 Subject: [PATCH 276/316] [globals] Use register_apply_action for globals.set (#19481) --- esphome/components/globals/__init__.py | 34 +++++-------------- .../components/globals/globals_component.h | 18 ++-------- tests/components/globals/common.yaml | 4 +-- 3 files changed, 14 insertions(+), 42 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index bd6bc5f783..8adf4e983c 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,8 +8,7 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -21,7 +20,6 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) -GlobalVarSetAction = globals_ns.class_("GlobalVarSetAction", automation.Action) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -94,33 +92,19 @@ async def to_code(config: ConfigType) -> None: cg.add(glob.set_name_hash(hash_)) -@automation.register_action( +# A lambda returns the global's own type so `return 42;` reduces to a cast; the value +# itself is raw C++ text. +_VALUE_TYPE = "std::remove_reference_tvalue())>" + +automation.register_apply_action( "globals.set", - GlobalVarSetAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GlobalsComponent), cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField( + CONF_VALUE, "value() = {}", _VALUE_TYPE, const_fn=lambda _, value: value + ), ) -async def globals_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - var = cg.new_Pvariable(action_id, template_arg, paren) - # Use the global's value_type alias as the lambda return type so - # TemplatableFn stores a direct function pointer instead of going through - # the deprecated converting trampoline when the value expression deduces - # to a different type (e.g. int literal assigned to a float global). - value_type = cg.RawExpression(f"{full_id.type}::value_type") - templ = await cg.templatable( - config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression - ) - cg.add(var.set_value(templ)) - return var diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 78d2bc5910..b7acb06569 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -1,8 +1,10 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include #include namespace esphome::globals { @@ -127,20 +129,6 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction final : public Action { - public: - explicit GlobalVarSetAction(C *parent) : parent_(parent) {} - - using T = typename C::value_type; - - TEMPLATABLE_VALUE(T, value); - - void play(const Ts &...x) override { this->parent_->value() = this->value_.value(x...); } - - protected: - C *parent_; -}; - template T &id(GlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalStringComponent *value) { return value->value(); } diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 6d5721d3be..bff7de8b7f 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,8 +4,8 @@ esphome: - globals.set: id: glob_int value: "10" - # Set a float global with an integer literal - must emit the correct - # return type so TemplatableFn stores a direct function pointer. + # Set a float global with an integer literal; the lambda must return the + # global's own type so the assignment needs no conversion. - globals.set: id: glob_float value: "102" From c1d82168d8e3d3977b79fa83844703be277b0b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:26:20 +0100 Subject: [PATCH 277/316] [event] Use register_apply_action for event.trigger (#19508) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/event/__init__.py | 23 ++++++----------------- esphome/components/event/automation.h | 7 ------- tests/components/event/common.yaml | 12 ++++++++++++ 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 881107b713..f6c3094724 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,14 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@nohat"] @@ -40,8 +40,6 @@ event_ns = cg.esphome_ns.namespace("event") Event = event_ns.class_("Event", cg.EntityBase) EventPtr = Event.operator("ptr") -TriggerEventAction = event_ns.class_("TriggerEventAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _EVENT_SCHEMA = ( @@ -135,20 +133,11 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) -@automation.register_action( - "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True +automation.register_apply_action( + "event.trigger", + TRIGGER_EVENT_SCHEMA, + automation.ApplyField(CONF_EVENT_TYPE, "trigger", cg.std_string), ) -async def event_fire_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) - cg.add(var.set_event_type(templ)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 73a6336f78..ee1d1a13c4 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,13 +6,6 @@ namespace esphome::event { -template class TriggerEventAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, event_type) - - void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } -}; - class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { diff --git a/tests/components/event/common.yaml b/tests/components/event/common.yaml index 555d049c70..827f1ebb52 100644 --- a/tests/components/event/common.yaml +++ b/tests/components/event/common.yaml @@ -18,3 +18,15 @@ event: // Log using %.*s format for StringRef ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str()); } + +button: + - platform: template + name: Trigger Event + on_press: + - event.trigger: + id: some_event + event_type: template_event_type1 + - event.trigger: + id: some_event + event_type: !lambda |- + return id(some_event).has_event() ? "template_event_type2" : "template_event_type1"; From 2ddc6a074196969d87297a508f669d80011575cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:26:41 +0100 Subject: [PATCH 278/316] [sensor] Use register_apply_action for sensor.template.publish (#19507) --- esphome/components/sensor/__init__.py | 1 - esphome/components/sensor/automation.h | 11 ----------- esphome/components/template/sensor/__init__.py | 11 ++--------- tests/components/sensor/common.yaml | 11 +++++++++++ 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 3b632a1847..62d78a32dd 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -256,7 +256,6 @@ SensorPtr = Sensor.operator("ptr") ValueRangeTrigger = sensor_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -SensorPublishAction = sensor_ns.class_("SensorPublishAction", automation.Action) # Filters Filter = sensor_ns.class_("Filter") diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 35a4a29e0d..0821063434 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -20,17 +20,6 @@ class SensorRawStateTrigger final : public Trigger { } }; -template class SensorPublishAction final : public Action { - public: - SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(float, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - Sensor *sensor_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 55537a5636..f831f87d92 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -49,20 +49,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "sensor.template.publish", - sensor.SensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(sensor.Sensor), cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.float_), ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 1961c98685..b8f5183588 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -5,6 +5,17 @@ sensor: id: source_sensor lambda: return 42.0; update_interval: 1s + on_value: + - sensor.template.publish: + id: publish_target + state: 42.0 + - sensor.template.publish: + id: publish_target + state: !lambda "return x * 2.0;" + + - platform: template + name: "Publish Target" + id: publish_target # Streaming filters (window_size == send_every) - uses StreamingFilter base class - platform: copy From 20703f32bb72fb3978c08eef5276d2f2c34d100d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:01 +0100 Subject: [PATCH 279/316] [mitsubishi_cn105] Use register_apply_action for vane.control (#19477) --- .../components/mitsubishi_cn105/__init__.py | 65 +++---------------- .../components/mitsubishi_cn105/automation.h | 19 ------ tests/components/mitsubishi_cn105/common.yaml | 2 +- .../mitsubishi_cn105_component_tests.cpp | 8 --- 4 files changed, 10 insertions(+), 84 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 470b7be5fc..9502773616 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -10,8 +10,8 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_USE_FAHRENHEIT, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj +from esphome.core import ID +from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@crnjan"] @@ -32,7 +32,6 @@ MitsubishiCN105Component = mitsubishi_ns.class_( ) VaneState = mitsubishi_ns.struct("VaneState") -VaneCall = mitsubishi_ns.class_("VaneCall") VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") # The insertion order must match VALUES in @@ -59,10 +58,6 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) -VaneControlAction = mitsubishi_ns.class_( - "VaneControlAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -117,8 +112,9 @@ async def to_code(config: ConfigType) -> None: ) ) cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) + # User lambdas may use unqualified enum names. + cg.add_global(mitsubishi_ns.using) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): - cg.add_global(mitsubishi_ns.using) for conf in on_state: await automation.build_callback_automation( var, @@ -183,14 +179,6 @@ async def clear_temperature_action_to_code( return var -VANE_CONTROL_FIELDS = ( - ( - (CONF_VERTICAL, CONF_DIRECTION), - "vertical.set_direction", - VerticalVaneMode, - ), -) - VANE_CONTROL_ACTION_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), @@ -205,46 +193,11 @@ VANE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.vane.control", - VaneControlAction, VANE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + (CONF_VERTICAL, CONF_DIRECTION), "vertical.set_direction", VerticalVaneMode + ), + call="make_vane_call", ) -async def vane_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - cg.add_global(mitsubishi_ns.using) - parent = await cg.get_variable(config[CONF_ID]) - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) - for t, name in args - ] - forwarded_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for path, setter, type_ in VANE_CONTROL_FIELDS: - if (section := config.get(path[0])) is None: - continue - if (value := section.get(path[1])) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, - normalized_args, - return_type=type_, - ) - body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - [(VaneCall.operator("ref"), "call"), *normalized_args], - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h index f9ca3a47e6..f0bd771fd5 100644 --- a/esphome/components/mitsubishi_cn105/automation.h +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -4,8 +4,6 @@ #include "esphome/core/automation.h" -#include - namespace esphome::mitsubishi_cn105 { template @@ -22,21 +20,4 @@ class ClearRemoteTemperatureAction final : public Action, public Parented void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; -template class VaneControlAction final : public Action { - public: - using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); - - VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_vane_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - MitsubishiCN105Component *parent_; - ApplyFn apply_; -}; - } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 3f7e8c8f95..eb5b55aa06 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -37,4 +37,4 @@ esphome: - mitsubishi_cn105.vane.control: id: ac vertical: - direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; + direction: !lambda return VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp index 928d4123f6..17cb1cb8c7 100644 --- a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -63,12 +63,4 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); } -TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { - MitsubishiCN105Component hub; - VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); - - action.play(); - - EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); -} } // namespace esphome::mitsubishi_cn105::testing From 21dc6af22e3b9b5739ba502f05fde516163d0ad3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:29 +0100 Subject: [PATCH 280/316] [binary_sensor] Use register_apply_action for binary_sensor.template.publish (#19506) --- esphome/components/binary_sensor/__init__.py | 3 --- esphome/components/binary_sensor/automation.h | 14 -------------- .../components/template/binary_sensor/__init__.py | 11 ++--------- tests/components/binary_sensor/common.yaml | 10 ++++++++++ 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a114ab4205..001a055d7c 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -134,9 +134,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorPublishAction = binary_sensor_ns.class_( - "BinarySensorPublishAction", automation.Action -) BinarySensorInvalidateAction = binary_sensor_ns.class_( "BinarySensorInvalidateAction", automation.Action ) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index d5a85ca9c4..1c331d22bd 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -165,20 +165,6 @@ template class BinarySensorCondition final : public Condition class BinarySensorPublishAction final : public Action { - public: - explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto val = this->state_.value(x...); - this->sensor_->publish_state(val); - } - - protected: - BinarySensor *sensor_; -}; - template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 07028f7dff..b2d6eca260 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -60,20 +60,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.template.publish", - binary_sensor.BinarySensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index d0a16cc99c..f9eda80c3f 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -37,6 +37,16 @@ binary_sensor: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.template.publish: + id: publish_target + state: true + - binary_sensor.template.publish: + id: publish_target + state: !lambda "return x.value_or(false);" + + - platform: template + id: publish_target + name: "Publish Target" # Test autorepeat with default configuration (no timings) - platform: template From 1be1f675162824586f3c72f8854dbf9796a469e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:34 +0100 Subject: [PATCH 281/316] [climate] Use register_apply_action for climate.control (#19475) --- esphome/components/climate/__init__.py | 111 ++++++++---------------- esphome/components/climate/automation.h | 28 ------ tests/components/climate/common.yaml | 20 ++++- 3 files changed, 52 insertions(+), 107 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 3fbca1a6d0..4c9f58c7a4 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -50,18 +50,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import ( - LambdaExpression, - MockObj, - MockObjClass, - TemplateArgsType, -) +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -169,7 +164,6 @@ def visual_temperature_step(value: Any) -> ConfigType: # Actions -ControlAction = climate_ns.class_("ControlAction", automation.Action) StateTrigger = climate_ns.class_( "StateTrigger", automation.Trigger.template(Climate.operator("ref")) ) @@ -500,78 +494,41 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +def _literal_with_length(config: ConfigType, value: str) -> str: + # The (const char *, size_t) overload compares bytes in place, so it needs a plain + # literal rather than the default PROGMEM rendering on ESP8266, and skips a strlen. + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + +automation.register_apply_action( "climate.control", - ControlAction, CLIMATE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_mode", ClimateMode), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, + "set_fan_mode", + cg.std_string, + const_fn=_literal_with_length, + ), + automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, + "set_preset", + cg.std_string, + const_fn=_literal_with_length, + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + call="make_call", ) -async def climate_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # For custom_fan_mode/custom_preset the static-string path emits the - # (const char *, size_t) overload of set_fan_mode/set_preset to avoid - # constructing a std::string and calling runtime strlen. - FIELDS = ( - (CONF_MODE, "set_mode", ClimateMode), - (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), - (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), - (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), - (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), - (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), - (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), - (CONF_PRESET, "set_preset", ClimatePreset), - (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), - (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - elif type_ is cg.std_string: - # Static custom strings: emit a flash literal and pass the - # UTF-8 byte length to skip the runtime strlen inside - # set_fan_mode/set_preset. - literal = cg.safe_exp(value) - body_lines.append( - f"call.{setter}({literal}, {len(value.encode('utf-8'))});" - ) - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ClimateCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index a8d6d778ae..d6aba02f92 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,34 +5,6 @@ namespace esphome::climate { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `target_temperature: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); - ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->climate_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Climate *climate_; - ApplyFn apply_; -}; - class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 49386a16d5..3e305d921a 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -56,8 +56,8 @@ climate: default_target_temperature_high: 22°C button: - # Exercise the climate.control: action so ControlAction templates get - # build coverage. Various field combinations are tested. + # Exercise the climate.control: action so the register_apply_action codegen + # gets build coverage. Various field combinations are tested. - platform: template name: "Climate Control Mode" on_press: @@ -84,6 +84,22 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + # Custom strings take the (const char *, size_t) overload; the byte length of + # the non-ASCII value is computed at codegen time. + - platform: template + name: "Climate Control Custom Strings" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: "Über" + custom_preset: Default + - platform: template + name: "Climate Control Custom Lambdas" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: !lambda return "quiet"; + custom_preset: !lambda return "Default"; # Exercise climate.control inside a trigger with non-empty Ts (number on_value # passes float). From b7b1507dafc8f403c6a377409ab0d8505e71bbc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:28:03 +0100 Subject: [PATCH 282/316] [update] Use register_apply_action for update.perform (#19505) --- esphome/components/update/__init__.py | 20 ++------------------ esphome/components/update/automation.h | 7 ------- tests/components/update/common.yaml | 2 ++ 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 5ebe58881d..818358c48a 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -32,9 +32,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -PerformAction = update_ns.class_( - "PerformAction", automation.Action, cg.Parented.template(UpdateEntity) -) CheckAction = update_ns.class_( "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) ) @@ -133,9 +130,8 @@ async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) -@automation.register_action( +automation.register_apply_action( "update.perform", - PerformAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), @@ -144,20 +140,8 @@ async def to_code(config: ConfigType) -> None: ): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_FORCE_UPDATE, "perform", cg.bool_), ) -async def update_perform_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - force = await cg.templatable(config[CONF_FORCE_UPDATE], args, cg.bool_) - cg.add(var.set_force(force)) - return var @automation.register_action( diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 8ba7b71a9c..3d303fdb63 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,13 +6,6 @@ namespace esphome::update { -template class PerformAction final : public Action, public Parented { - TEMPLATABLE_VALUE(bool, force) - - public: - void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } -}; - template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } diff --git a/tests/components/update/common.yaml b/tests/components/update/common.yaml index 40042945c8..bcfdbd8c0b 100644 --- a/tests/components/update/common.yaml +++ b/tests/components/update/common.yaml @@ -13,6 +13,8 @@ esphome: - update.check: - update.perform: force_update: true + - update.perform: + force_update: !lambda return false; wifi: ssid: MySSID From 438307f548a63fb7b6184c89acb93e8ab09ab846 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:28:36 +0100 Subject: [PATCH 283/316] [light] Use register_apply_action for the light control actions (#19476) --- esphome/components/light/automation.h | 28 ---- esphome/components/light/automation.py | 125 ++++++------------ esphome/components/light/types.py | 2 - .../components/http_request/http_request.yaml | 2 +- tests/components/light/common.yaml | 10 ++ .../integration/test_light_control_action.py | 4 +- 6 files changed, 55 insertions(+), 116 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index ced15dfc60..5ce5f1ee60 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,34 +31,6 @@ template class ToggleAction final : pu transition_length_{}; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `brightness: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction final : public Action { - public: - using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); - LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - this->apply_(this->parent_, call, x...); - call.perform(); - } - - protected: - LightState *parent_; - ApplyFn apply_; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 7eaba9b117..d84df33a23 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -27,7 +27,7 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -37,8 +37,6 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, - LightCall, - LightControlAction, LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, @@ -147,7 +145,7 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -def _resolve_effect_index(config: ConfigType) -> int: +def _resolve_effect_index(config: ConfigType, original_name: str) -> int: """Resolve a static effect name to its 1-based index at codegen time. Effect index 0 means "None" (no effect). Effects are 1-indexed matching @@ -155,7 +153,6 @@ def _resolve_effect_index(config: ConfigType) -> int: """ from . import available_effects_str, find_effect_index - original_name = config[CONF_EFFECT] if original_name.lower() == "none": return 0 light_id = config[CONF_ID] @@ -173,87 +170,49 @@ def _resolve_effect_index(config: ConfigType) -> int: ) -@automation.register_action( - "light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True +def _effect_index(config: ConfigType, value: str) -> str: + # Resolved at codegen time; the cast picks set_effect(uint32_t) over the optional overload. + return f"static_cast({_resolve_effect_index(config, value)})" + + +_LIGHT_CONTROL_FIELDS = ( + automation.ApplyField(CONF_COLOR_MODE, "set_color_mode", ColorMode), + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + automation.ApplyField(CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + automation.ApplyField(CONF_RED, "set_red", cg.float_), + automation.ApplyField(CONF_GREEN, "set_green", cg.float_), + automation.ApplyField(CONF_BLUE, "set_blue", cg.float_), + automation.ApplyField(CONF_WHITE, "set_white", cg.float_), + automation.ApplyField(CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + automation.ApplyField(CONF_COLD_WHITE, "set_cold_white", cg.float_), + automation.ApplyField(CONF_WARM_WHITE, "set_warm_white", cg.float_), + automation.ApplyField( + CONF_EFFECT, "set_effect", cg.std_string, const_fn=_effect_index + ), ) -@automation.register_action( - "light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True + +automation.register_apply_action( + "light.turn_off", + LIGHT_TURN_OFF_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="make_call", ) -@automation.register_action( - "light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "light.turn_on", + LIGHT_TURN_ON_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", +) +automation.register_apply_action( + "light.control", + LIGHT_CONTROL_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", ) -async def light_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", cg.bool_), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", cg.float_), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), - (CONF_RED, "set_red", cg.float_), - (CONF_GREEN, "set_green", cg.float_), - (CONF_BLUE, "set_blue", cg.float_), - (CONF_WHITE, "set_white", cg.float_), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), - (CONF_COLD_WHITE, "set_cold_white", cg.float_), - (CONF_WARM_WHITE, "set_warm_white", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if conf_key not in config: - continue - value = config[conf_key] - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - if CONF_EFFECT in config: - if isinstance(config[CONF_EFFECT], Lambda): - inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], normalized_args, return_type=cg.std_string - ) - body_lines.append( - f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"call.set_effect(parent->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size())); }}" - ) - else: - # Cast disambiguates between set_effect(uint32_t) and - # set_effect(optional) when the literal is an int. - body_lines.append( - f"call.set_effect(static_cast({_resolve_effect_index(config)}));" - ) - - apply_args = [ - (LightState.operator("ptr"), "parent"), - (LightCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 1778aa8410..4d2de314f6 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -14,7 +14,6 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") -LightCall = light_ns.class_("LightCall") # Addressable strips ChannelColors = light_ns.struct("ChannelColors") @@ -42,7 +41,6 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) -LightControlAction = light_ns.class_("LightControlAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 4b3c2ca36b..172e0c235a 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -50,7 +50,7 @@ esphome: format: "After delay, body still: %s" args: - body.c_str() - # Regression test for esphome/esphome#16224: a LightControlAction + # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen # bug where the apply lambda's parameter list did not match the diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f10..5ba245e7c3 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -77,6 +77,16 @@ esphome: - light.control: id: test_monochromatic_light state: on + # flash_length and a turn_off with its own transition + - light.turn_on: + id: test_rgb_light + flash_length: 750ms + - light.turn_on: + id: test_rgb_light + flash_length: !lambda return 250; + - light.turn_off: + id: test_rgb_light + transition_length: 300ms # Test static effect name resolution at codegen time - light.turn_on: id: test_monochromatic_light diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py index 9a5c16a04d..bb690645bd 100644 --- a/tests/integration/test_light_control_action.py +++ b/tests/integration/test_light_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for LightControlAction. +"""Integration test for the light control apply actions. Tests that light.turn_on, light.turn_off, and light.control automation actions work correctly with the compact per-field union storage. Exercises both constant @@ -19,7 +19,7 @@ async def test_light_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test LightControlAction with constants and lambdas.""" + """Test light.turn_on, light.turn_off and light.control with constants and lambdas.""" async with run_compiled(yaml_config), api_client_connected() as client: state_futures: dict[int, asyncio.Future[Any]] = {} From e3951f2ab43233056f1de81ab33d04b1d909181a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:30:19 +0100 Subject: [PATCH 284/316] [esp8266_pwm] Use register_apply_action for the set_frequency action (#19516) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 14 -------------- esphome/components/esp8266_pwm/output.py | 19 ++----------------- tests/components/esp8266_pwm/common.yaml | 10 ++++++++++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 87b76a392a..7b36c410a2 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" namespace esphome::esp8266_pwm { @@ -37,19 +36,6 @@ class ESP8266PWM final : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - ESP8266PWM *parent_; -}; - } // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index be6e63b154..62ec1fe003 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,8 +4,6 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] @@ -19,7 +17,6 @@ def valid_pwm_pin(value: ConfigType) -> ConfigType: esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm") ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) # Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen @@ -57,25 +54,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(frequency)) -@automation.register_action( +automation.register_apply_action( "output.esp8266_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(ESP8266PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/tests/components/esp8266_pwm/common.yaml b/tests/components/esp8266_pwm/common.yaml index 52b290f91b..7144e89e36 100644 --- a/tests/components/esp8266_pwm/common.yaml +++ b/tests/components/esp8266_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.esp8266_pwm.set_frequency: + id: out + frequency: 100Hz + - output.esp8266_pwm.set_frequency: + id: out2 + frequency: !lambda return 200.0f; + output: - platform: esp8266_pwm id: out From f4300d9ceebdb012141fd4fcb612c462311591af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:45:54 +0100 Subject: [PATCH 285/316] [esp8266] Add the native ninja emission (#18560) --- esphome/build_gen/arduino8266.py | 502 +++++++++++++++- esphome/components/esp8266/__init__.py | 6 +- esphome/components/esp8266/const.py | 4 +- .../unit_tests/build_gen/test_arduino8266.py | 553 +++++++++++++++++- 4 files changed, 1032 insertions(+), 33 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 7d842308f6..b6ac88f123 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1,11 +1,11 @@ -"""Build specification for the native ESP8266 Arduino toolchain. +"""Native ninja build generator for the ESP8266 Arduino core. Transliterates the PlatformIO build spec for the Arduino ESP8266 framework (``framework-arduinoespressif8266/tools/platformio-build.py`` plus -``platform-espressif8266/builder/main.py``): the flag sets, defines, and -linker-script generation deliberately match what PlatformIO produces so the -binaries stay near-identical between the two toolchains. The ninja emission -(``write_project``) builds on these pieces. +``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under +``.pioenvs//``. The flag sets, defines, link line, linker-script +generation, and ``elf2bin`` invocation deliberately match what PlatformIO +produces so the binaries stay near-identical between the two toolchains. The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK version, MMU layout, exceptions, waveform phase) keep working: they are read @@ -14,6 +14,7 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations +from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -22,20 +23,51 @@ from pathlib import Path import re import shlex import subprocess +import sys from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool -from esphome.build_helpers.ninja import shell_token as _shell_token +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import ( + BOARDS, + ESP8266_BOARD_BUILD, + board_ld_script, +) +from esphome.components.esp8266.const import ( + BUILD_FLASH_MODES, + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_SIZE, + KEY_SCANF_FLOAT, +) from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + get_project_cxx_compile_flags, + strip_win_long_path_prefix, +) from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import lex_build_flags +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths _LOGGER = logging.getLogger(__name__) +# Always excluded from the core build: ESPHome uses its own native OTA +# backend, so the Arduino Updater (and its 228-byte global) never links. +_CORE_EXCLUDE_ALWAYS = {"Updater.cpp"} +# Excluded when no component called require_waveform(); waveform_stubs.cpp +# supplies the stopWaveform()/_stopPWM() stubs digitalWrite needs. +_CORE_EXCLUDE_WAVEFORM = { + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", +} + # Values that land unquoted on generated command lines are shape-checked # against these before use. re.ASCII: a Unicode digit or word character # (Arabic-Indic numerals) would pass \d/\w and defeat the named error @@ -77,6 +109,11 @@ def _apply_surgery(fn, *args: object) -> str: raise EsphomeError(str(err)) from err +# Every supported board's f_flash is 40 MHz; re-check on a platform bump +# board_flash_mode's closed set, shared with cv.one_of's validation +_FLASH_MODES = frozenset(BUILD_FLASH_MODES) +_FLASH_FREQ_MHZ = 40 + # From platformio-build.py. Knob suffix -> SDK define; the first entry is # the default (dicts preserve insertion order). With multiple SDK knobs set # (a pathological config) ties break by table order, since upstream's @@ -408,6 +445,52 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: ) +_INCOMPLETE_INSTALL = "Arduino toolchain install is incomplete" + + +def _filter_link_flags(unflags: set[str]) -> list[str]: + """_LINKFLAGS minus ``unflags``, pair-aware: unflagging a symbol also + drops the ``-u`` that carried it, so no dangling operand-less flag + reaches ld as the next token's consumer.""" + out: list[str] = [] + it = iter(_LINKFLAGS) + for tok in it: + if tok == "-u": + symbol = next(it) + if symbol not in unflags: + out += [tok, symbol] + elif tok not in unflags: + out.append(tok) + return out + + +def _active_flash_ld_name(flash_ld_name: str) -> str: + """The flash linker-script filename the link uses (testing mode renames + the surgically patched copy).""" + return ( + f"{_TESTING_LD_PREFIX}{flash_ld_name}" if CORE.testing_mode else flash_ld_name + ) + + +def _flash_ld_name(board: str) -> str: + """The flash linker script: the board's, or a routed user override. + + Published configs override board_build.ldscript to reserve a + filesystem region or correct a board's assumed flash size; a bare + name is required because the script resolves via the -L search path. + """ + override = _pio_option("board_build.ldscript", "") + if not override: + # The same shared rule the PlatformIO path pins (layout + # preservation, see boards.board_ld_script) + return board_ld_script(BOARDS[board]) + if Path(override).name != override: + raise EsphomeError( + f"board_build.ldscript must be a bare script name, got {override!r}" + ) + return override + + def _pio_option(key: str, default: str) -> str: """A platformio_options value the native build honors (str-normalized). @@ -551,11 +634,23 @@ _PLAIN_LINKER_FLAGS = ( "-nostdlib", "-rdynamic", ) +# The subset whose next token is an operand; unflagging the bare flag +# would strand the operand. Operand-less members of the list above filter +# whole-token from both the compile and link lines, as PlatformIO allows. +_PLAIN_LINKER_OPERAND_FLAGS = ("-u", "-e") _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") # Driver options, not ld options: -Wl, has no equivalent for these _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") +def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + def _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -771,3 +866,396 @@ def _generate_testing_flash_ld( write_file_if_changed( ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld ) + + +def _ninja_compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths.""" + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + lines.append( + f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" + ) + if flags: + lines.append(f" flags = {flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping. + objects.append(escaped_obj) + return objects + + +def _common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +class _FlagSets(NamedTuple): + cflags: list[str] + cxxflags: list[str] + asflags: list[str] + + +def _check_install(framework: Path, src_dir: Path, include_dirs: list[Path]) -> None: + """Fail by naming the missing path before any tool runs.""" + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + # A missing install directory would otherwise surface as a wall of + # include errors; failing here names the path instead + for required in include_dirs[1:]: + if not required.is_dir(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required}; {_CLEAN_HINT}" + ) + # The elf2bin edge runs after the full compile and link; a + # half-extracted package must fail here, not an hour of wall-clock later + for required_file in ( + framework / "tools" / "elf2bin.py", + framework / "bootloaders" / "eboot" / "eboot.elf", + ): + if not required_file.is_file(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required_file}; {_CLEAN_HINT}" + ) + + +def _resolve_flag_sets( + config: _BuildConfig, + defines: list[str], + includes: list[str], + project_compile_flags: list[str], + build_tokens: list[str], + unflags: set[str], +) -> _FlagSets: + """The c/cxx/as flag sets with ``build_unflags`` applied.""" + common = _CCFLAGS + defines + includes + project_compile_flags + cflags = _CFLAGS + common + cpp_standard = CORE.cpp_standard or "gnu++17" + cxxflags = ( + ["-fno-rtti", f"-std={cpp_standard}"] + + ["-fexceptions" if config.exceptions else "-fno-exceptions"] + + common + + [_shell_token(f) for f in get_project_cxx_compile_flags()] + ) + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly; match + # it (tokens arrive shell-quoted, hence the lstrip) + asflags = ( + _ASFLAGS + + defines + + includes + + [f for f in project_compile_flags if f.lstrip("\"'").startswith(("-D", "-I"))] + ) + # build_unflags applies to the framework flag sets too, as under + # PlatformIO; matching is whole-token, so an unflag that hits nothing + # anywhere must be visible + flag_universe = set(build_tokens) + for flags in (cflags, cxxflags, asflags, _LINKFLAGS): + flag_universe.update(flags) + if unmatched := sorted(unflags - flag_universe): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", ", ".join(unmatched) + ) + # _LINKFLAGS stores -u and its operand as two tokens; unflagging the + # bare -u would strip all seven and leave the operands as ld "input + # files" with an error pointing nowhere near build_unflags + if plain := sorted( + u + for u in unflags + if u in _PLAIN_LINKER_OPERAND_FLAGS or u.startswith(_PLAIN_LINKER_PREFIXES) + ): + raise EsphomeError( + f"build_unflags cannot remove plain linker flag(s) " + f"{', '.join(plain)}; unflag the full -Wl, form or the symbol" + ) + return _FlagSets( + *( + [f for f in flags if f not in unflags] + for flags in (cflags, cxxflags, asflags) + ) + ) + + +def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled) + so one build never pays the runnability probe per consumer. Returns + True when ``build.ninja`` changed, so the caller can skip work derived + purely from it (the compile database) on unchanged builds. + """ + from esphome.arduino.library import resolve_libraries + + framework = paths.framework + toolchain_bin = paths.toolchain / "bin" + build_dir = CORE.relative_pioenvs_path(CORE.name) + mkdir_p(build_dir) + + unflags = _unflag_tokens() + # Lexed once so a malformed entry warns once, not per consumer + build_tokens = _lexed_build_flags() + flag_defines = _flag_defines(unflags, build_tokens) + config = _resolve_build_config(flag_defines) + esp8266_data = CORE.data[KEY_ESP8266] + board = esp8266_data[KEY_BOARD] + # Config validation already gates boards; + # kept as defense-in-depth for direct calls, since CONF_BOARD itself is + # a free-form string + if board not in ESP8266_BOARD_BUILD: + raise EsphomeError(f"Board '{board}' is not supported by the native toolchain") + board_build = ESP8266_BOARD_BUILD[board] + # From the same producer the PlatformIO path reads (one source) + flash_mode = _pio_option("board_build.flash_mode", "dout") + if flash_mode not in _FLASH_MODES: + # Lands unquoted in the elf2bin command and a -D body; validation + # (cv.one_of on board_flash_mode) already gates it, defense-in-depth + raise EsphomeError(f"Invalid flash mode {flash_mode!r}") + flash_ld_name = _flash_ld_name(board) + + sdk = framework / "tools" / "sdk" + core_dir = framework / "cores" / "esp8266" + variant_dir = framework / "variants" / board_build["variant"] + src_dir = CORE.relative_src_path() + + include_dirs = [ + src_dir, + sdk / "include", + core_dir, + paths.toolchain / "include", + sdk / "lwip2" / "include", + variant_dir, + ] + # Completeness checks run before generate_ld_scripts spawns gcc so a + # half-extracted install names the missing path, not a gcc error + _check_install(framework, src_dir, include_dirs) + + generate_ld_scripts(paths, config, flash_ld_name) + + libraries = resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + for lib in libraries: + include_dirs += lib.include_dirs + + ( + project_compile_flags, + project_link_flags, + project_lib_dirs, + project_libs, + ) = _project_flags(unflags, build_tokens) + flag_sets = _resolve_flag_sets( + config, + _defines_flags(config, flash_mode, board, board_build["defines"]), + [f"-I{_q(d)}" for d in include_dirs], + project_compile_flags, + build_tokens, + unflags, + ) + link_flags = _filter_link_flags(unflags) + if esp8266_data[KEY_SCANF_FLOAT]: + link_flags += ["-u", "_scanf_float"] + link_flags += project_link_flags + link_flags += [_shell_token(flag) for lib in libraries for flag in lib.link_flags] + flash_ld = _active_flash_ld_name(flash_ld_name) + # A user-overridden script name re-quotes like every other user token + link_flags += ["-T", _shell_token(flash_ld)] + + lib_dirs = [Path("ld"), sdk / "lib", sdk / "ld", sdk / "lib" / config.nonosdk] + lib_dirs += project_lib_dirs + for lib in libraries: + lib_dirs += lib.link_dirs + system_libs = ( + _SYSTEM_LIBS_PRE_LWIP + + [config.lwip_lib] + + _SYSTEM_LIBS_POST_LWIP + + project_libs + + [lib_name for lib in libraries for lib_name in lib.link_libs] + + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] + ) + + build_tool = Path(__file__).parent / "build_tool.py" + + # $in/$out stay unquoted: ninja escapes its built-in path variables + # itself; only literal paths need _q(). + lines = [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", + f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(build_tool)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) + "rule c", + " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + "rule ar", + f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + "rule elf2bin", + # --flash_size deliberately stays board-derived, as under + # PlatformIO (which reads upload.maximum_size, not the ldscript). + f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + " description = BIN $out", + "rule copy", + " command = $python $buildtool copy $in $out", + " description = COPY $out", + "", + f"cflags = {' '.join(flag_sets.cflags)}", + f"cxxflags = {' '.join(flag_sets.cxxflags)}", + f"asflags = {' '.join(flag_sets.asflags)}", + f"linkflags = {' '.join(link_flags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(_shell_token(f'-l{lib}') for lib in system_libs)}", + "", + ] + + core_exclude = set(_CORE_EXCLUDE_ALWAYS) + if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: + core_exclude |= _CORE_EXCLUDE_WAVEFORM + + archives = [] + direct_objs: list[str] = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = _collect_sources(variant_dir) + if variant_sources: + objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = _ninja_compile_edges( + lines, _collect_sources(core_dir, core_exclude), core_dir, "core" + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do. + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + lib_root = _common_parent(lib.sources) + objs = _ninja_compile_edges( + lines, + lib.sources, + lib_root, + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + + # One source of truth with the PlatformIO path: esp8266/__init__ pins + # build_src_flags (the throw_stubs force-include); -include paths + # resolve against the source root + src_parts: list[str] = [] + src_it = iter( + lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") + ) + for tok in src_it: + if tok == "-include": + header = next(src_it, "") + if not header: + raise EsphomeError( + "build_src_flags has a trailing '-include' with no header" + ) + src_parts.append(f"-include {_q(src_dir / header)}") + else: + src_parts.append(_shell_token(tok)) + src_extra = " ".join(src_parts) + # One shared variable instead of repeating the flags line on every src + # edge (hundreds of edges in a real project) + lines.append(f"srcflags = {src_extra}") + src_objs = _ninja_compile_edges( + lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags" + ) + + ld_deps = [f"ld/{_COMMON_LD_NAME}"] + if CORE.testing_mode: + ld_deps.append(f"ld/{flash_ld}") + lines.append( + f"build firmware.elf: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)} {' '.join(_e(d) for d in ld_deps)}" + ) + lines.append(f" archives = {' '.join(_shell_token(a) for a in archives)}") + lines.append("build firmware.bin: elf2bin firmware.elf") + lines.append("build firmware.factory.bin: copy firmware.bin") + lines.append("build firmware.ota.bin: copy firmware.bin") + lines.append("default firmware.factory.bin firmware.ota.bin") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) + + +def get_flash_ld_path(build_dir: Path, paths: InstalledPaths) -> Path: + """The flash linker script the link actually uses (for size reporting). + + Reads the same install the ninja file linked against instead of + re-resolving the framework version. A user-shipped override living in a + custom -L dir resolves to a nonexistent path here; the size consumer + warns and skips the Flash summary then. + """ + name = _active_flash_ld_name(_flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])) + if CORE.testing_mode: + return build_dir / "ld" / name + return paths.framework / "tools" / "sdk" / "ld" / name + + +def _flash_size_str(flash_size: int) -> str: + """Flash size argument for elf2bin (e.g. ``4M``, ``512K``).""" + mb = 1024 * 1024 + return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index cef0e6ea11..56509b9fab 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -37,6 +37,7 @@ from esphome.types import ConfigType from .boards import BOARDS, board_ld_script from .const import ( + BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, @@ -44,6 +45,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, + KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, @@ -233,7 +235,6 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( ) -BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -315,6 +316,9 @@ async def to_code(config: ConfigType) -> None: "enabling scanf float support (~8KB flash)" ) + # The native generator reads the same decision (KEY_SCANF_FLOAT) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) + extra_scripts = [ "pre:ccache.py", "pre:testing_mode.py", diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 50f103ed2d..ac2fe0aa3b 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -16,7 +16,6 @@ KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator -KEY_FLASH_MODE = "flash_mode" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" @@ -73,3 +72,6 @@ def enable_serial1() -> None: enable_serial1() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True + + +BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout") diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 21a81804ab..4ad88347e2 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1,38 +1,34 @@ -"""Drift tests for the native ESP8266 Arduino build spec. +"""Drift tests for the native ESP8266 Arduino build generator. -These pin the ESPHome side of the transliteration (the knob-define -precedence, the define/flag sets, and the linker-script generation) against -literals audited from the PlatformIO builder -(framework-arduinoespressif8266/tools/platformio-build.py and -platform-espressif8266/builder/main.py). They catch an accidental edit on -this side; an upstream change in a new framework release is caught by the -A/B byte-identical build check on a version bump, not by these tests. +Pin the transliterated flag/define/link sets against literals audited from +the PlatformIO builder. Upstream drift is caught by the A/B build check on +version bumps, not here. """ from __future__ import annotations from collections.abc import Generator +import logging import os from pathlib import Path +import shutil from unittest.mock import MagicMock, patch import pytest +from esphome.arduino.library import ArduinoLibrary from esphome.arduino8266.framework import InstalledPaths, toolchain_tool from esphome.build_gen import arduino8266 from esphome.build_gen.arduino8266 import ( _defines_flags, _flag_defines, + _flash_size_str, _resolve_build_config, + get_flash_ld_path, ) -from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import ( - KEY_BOARD, - KEY_ESP8266, - KEY_FLASH_MODE, - KEY_SCANF_FLOAT, -) +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -47,9 +43,13 @@ def _setup_core(tmp_path: Path) -> Generator[None]: CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} CORE.data[KEY_ESP8266] = { KEY_BOARD: "nodemcuv2", - KEY_FLASH_MODE: "dout", KEY_SCANF_FLOAT: False, } + # The producers esp8266/__init__ pins unconditionally + CORE.platformio_options = { + "board_build.flash_mode": "dout", + "build_src_flags": "-include esphome/components/esp8266/throw_stubs.h", + } yield # CORE.reset() (the suite-wide autouse fixture) does not clear this flag CORE.testing_mode = False @@ -59,6 +59,11 @@ def _set_flags(*flags: str) -> None: CORE.build_flags = set(flags) +def _shq(tok: str) -> str: + """The platform's shell_token quote wrapper (argv rule on Windows).""" + return f'"{tok}"' if os.name == "nt" else f"'{tok}'" + + def _resolve(*flags: str) -> arduino8266._BuildConfig: """Set the build flags and resolve the knob config in one step.""" _set_flags(*flags) @@ -206,12 +211,173 @@ def _make_framework(tmp_path: Path) -> InstalledPaths: for sub in ("include", "ld", "lwip2/include", "lib"): (framework / "tools" / "sdk" / sub).mkdir(parents=True) (framework / "libraries").mkdir() + (framework / "tools" / "elf2bin.py").write_text("") + eboot = framework / "bootloaders" / "eboot" + eboot.mkdir(parents=True) + (eboot / "eboot.elf").write_text("") toolchain = tmp_path / "toolchain" (toolchain / "bin").mkdir(parents=True) (toolchain / "include").mkdir() return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) +def _write_ninja( + paths: InstalledPaths, + libraries: list | None = None, + ccache: str | None = None, +) -> str: + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + (src / "esphome" / "vendor.c").write_text("") + + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch( + "esphome.arduino.library.resolve_libraries", + return_value=libraries or [], + ), + ): + arduino8266.write_project(paths, ccache) + return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text() + + +def test_write_project_rejects_bad_flash_mode(tmp_path: Path) -> None: + """A flash mode outside the closed set fails by name before landing + unquoted in the elf2bin command.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /" + with pytest.raises(EsphomeError, match="Invalid flash mode"): + _write_ninja(paths) + + +def test_write_project_flash_mode_reaches_define_and_elf2bin( + tmp_path: Path, +) -> None: + """A non-default board_flash_mode lands in both the compile define and + the elf2bin image header, never silently falling back to dout.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dio" + content = _write_ninja(paths) + assert "-DFLASHMODE_DIO" in content + assert "--flash_mode dio" in content + assert "FLASHMODE_DOUT" not in content + + +def test_write_project_trailing_include_raises(tmp_path: Path) -> None: + """A dangling -include must fail by name, not become -include .""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = "-include" + with pytest.raises(EsphomeError, match="trailing '-include'"): + _write_ninja(paths) + + +def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None: + """Non-include build_src_flags tokens are requoted onto the src edges.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = ( + "-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1" + ) + content = _write_ninja(paths) + assert "throw_stubs.h" in content + assert "-DSRC_ONLY=1" in content + + +def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + "-DUSE_ESP8266_WAVEFORM_STUBS", + "-Wl,--wrap=millis", + "-Wl,--wrap=printf", + "-Wno-nonnull-compare", + "-L/opt/blobs", + "-luser_blob", + "-L /spc/blobs -l spaced_blob", + ) + content = _write_ninja(paths) + + # Base link flags from the PlatformIO builder + for flag in ( + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u app_entry", + "-u _printf_float", + "-u _DebugExceptionVector", + "-u _DoubleExceptionVector", + "-u _KernelExceptionVector", + "-u _NMIExceptionVector", + "-u _UserExceptionVector", + ): + assert flag in content + # ESPHome's link flags and the board linker script + assert "-Wl,--wrap=millis" in content + assert "-Wl,--wrap=printf" in content + assert "-T eagle.flash.4m.ld" in content + # scanf float disabled: the forced-link flag must not appear + assert "_scanf_float" not in content + # $in/$out must stay unquoted; ninja escapes its own path variables + assert "-c $in -o $out" in content + assert "--app $in --flash_mode" in content + assert '"$in"' not in content + assert '"$out"' not in content + # -L/-l from esphome build_flags reach the link line, not the compiles; + # spaced forms ("-L /path") are shell-lexed the way PlatformIO does. + # str(Path(...)) so the separator matches the host platform. + opt_blobs = str(Path("/opt/blobs")) + spc_blobs = str(Path("/spc/blobs")) + assert f"-L{_shq(opt_blobs)}" in content + assert "-luser_blob" in content + assert f"-L{_shq(spc_blobs)}" in content + assert "-lspaced_blob" in content + for line in content.splitlines(): + if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"): + assert "user_blob" not in line + assert opt_blobs not in line + assert "spaced_blob" not in line + assert spc_blobs not in line + # System libraries with the selected lwIP variant, in the builder's order + assert ( + "-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps " + "-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob " + "-luser_blob " + "-lstdc++ -lm -lc -lgcc" in content + ) + # Core exclusions: native OTA backend and waveform stubs + assert "Updater.cpp" not in content + assert "core_esp8266_waveform_pwm.cpp" not in content + assert "core_esp8266_waveform_phase.cpp" not in content + assert "core_esp8266_main.cpp.o" in content + # Assembly and C sources compile through their own rules + assert "cont.S.o: aspp" in content + assert "abi.c.o: c" in content + # throw_stubs is force-included for ESPHome sources only, via one shared + # srcflags variable rather than a copy of the flags line per edge + src_lines = [line for line in content.splitlines() if "obj/src/" in line] + assert any("main.cpp.o: cxx" in line for line in src_lines) + assert content.count("throw_stubs.h") == 1 + assert "srcflags = -include" in content + flags_lines = [ + line for line in content.splitlines() if line.startswith(" flags = ") + ] + assert flags_lines + assert all(line == " flags = $srcflags" for line in flags_lines) + + +def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "-u _scanf_float" in content + # Waveform not stubbed out: both implementations stay in the archive + assert "core_esp8266_waveform_pwm.cpp.o" in content + assert "core_esp8266_waveform_phase.cpp.o" in content + + @pytest.mark.parametrize( ("knob", "lib", "mss", "features", "ipv6"), [ @@ -360,8 +526,8 @@ def test_generate_ld_scripts(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) mock_run.assert_not_called() - # A surgery edit invalidates the stamp via the fingerprint (a stale - # linker script would otherwise persist until an esphome clean) + # An edit to the surgery constants invalidates the stamp (a stale linker + # script would otherwise persist until an esphome clean) with ( patch.object( arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" @@ -401,8 +567,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: paths = _make_framework(tmp_path) (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( - # Real flash ld scripts carry no iram1_0_seg (that lives in the - # generated common ld); the surgery rejects one it was not asked about "MEMORY\n{\n" " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" @@ -416,6 +580,96 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: assert "len = 0x2000000" in patched +def test_write_project_libraries_and_variant( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + + paths = _make_framework(tmp_path) + variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp" + variant_src.write_text("") + + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + (lib_dir / "impl.cc").write_text("") + headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir]) + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + link_dirs=[lib_dir / "blobs"], + link_libs=["algobsec"], + link_flags=["-Wl,--wrap=malloc"], + ) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") + + with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"): + content = _write_ninja( + paths, libraries=[library, headers_only], ccache="/cc/ccache" + ) + + assert "build libFrameworkArduinoVariant.a: ar" in content + assert "build libMyLib.a: ar" in content + # A headers-only library contributes includes but no archive, with a + # debug log distinguishing it from a resolution failure + assert "libHeadersOnly.a" not in content + assert "Library HeadersOnly has no source files" in caplog.text + assert " flags = -DMYLIB=1" in content + assert "-lalgobsec" in content + # Library link flags reach the firmware link line; .cc compiles as C++ + assert "-Wl,--wrap=malloc" in content + assert "impl.cc.o: cxx" in content + assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content + # Exceptions knob: -fexceptions and the exception-enabled stdc++ + assert "-fexceptions" in content + assert "-lstdc++-exc" in content + assert f"ccache = {_shq('/cc/ccache')}" in content + + +def test_get_flash_ld_path(tmp_path: Path) -> None: + + paths = InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=Path("ninja"), + ) + CORE.testing_mode = True + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "ld" / "testing_eagle.flash.4m.ld" + ) + + CORE.testing_mode = False + # Reads the same install the ninja file linked against; no re-resolve + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld" + ) + + +def test_flash_size_str() -> None: + assert _flash_size_str(4 * 1024 * 1024) == "4M" + assert _flash_size_str(512 * 1024) == "512K" + + +def test_write_project_testing_mode(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.testing_mode = True + _set_flags() + content = _write_ninja(paths) + assert "-T testing_eagle.flash.4m.ld" in content + assert "ld/testing_eagle.flash.4m.ld" in content + + +def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None: + """An incomplete framework install fails naming the missing path.""" + + paths = _make_framework(tmp_path) + shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2") + _set_flags() + with pytest.raises(EsphomeError, match="incomplete.*lwip2"): + _write_ninja(paths) + + def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( tmp_path: Path, ) -> None: @@ -440,6 +694,61 @@ def test_build_config_nonosdk_precedence() -> None: assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" +def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None: + """A lowercase .s source assembles plain (SCons AS), never through the + preprocessor rule that a .S source gets.""" + paths = _make_framework(tmp_path) + core_dir = paths.framework / "cores" / "esp8266" + (core_dir / "lowlevel.s").write_text("nop\n") + _set_flags() + content = _write_ninja(paths) + assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( + content + ) + + +def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None: + """build_unflags: -nostdlib filters whole-token from both lines, as + PlatformIO allows; only operand-taking flags hard-error.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-nostdlib"} + content = _write_ninja(paths) + assert "-nostdlib" not in content + + +def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None: + """Unflagging a -u symbol drops the -u that carried it; a dangling -u + would consume the next token and hand ld a symbol as an input file.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"_printf_float"} + content = _write_ninja(paths) + link_line = next( + line for line in content.splitlines() if line.startswith("linkflags = ") + ) + assert "_printf_float" not in link_line + assert "-u -u" not in link_line + # The neighbors survive as intact pairs + assert "-u app_entry" in link_line + assert "-u _DebugExceptionVector" in link_line + + +def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None: + """build_unflags removes flags from the framework sets, as PlatformIO does.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"} + content = _write_ninja(paths) + for line in content.splitlines(): + key = line.split(" = ")[0] + if key in ("cflags", "cxxflags", "asflags"): + assert "-fipa-pta" not in line + if key == "linkflags": + assert "-Wl,--gc-sections" not in line + + def test_project_flags_trailing_bare_linker_flag_warns( caplog: pytest.LogCaptureFixture, ) -> None: @@ -494,10 +803,18 @@ def test_project_flags_requotes_lexed_defines() -> None: compile_flags, _link, _dirs, _libs = _split_flags() # shlex folds the quotes (as PIO's ParseFlags does); _shell_token # re-quotes the spaced token so the shell passes one argv element - expected = ( - '"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'" - ) - assert compile_flags == [expected] + assert compile_flags == [_shq("-DGREETING=hello world")] + + +def test_write_project_empty_core_raises(tmp_path: Path) -> None: + """A framework tree with no core sources fails at generation, not link.""" + paths = _make_framework(tmp_path) + core = paths.framework / "cores" / "esp8266" + for f in core.iterdir(): + f.unlink() + _set_flags() + with pytest.raises(EsphomeError, match="no core sources"): + _write_ninja(paths) def test_flag_defines_joins_spaced_define() -> None: @@ -508,6 +825,75 @@ def test_flag_defines_joins_spaced_define() -> None: assert "" not in defines +def test_ninja_path_escaping() -> None: + """Build-statement paths and command-line paths escape differently.""" + assert arduino8266._e("a b:$c") == "a$ b$:$$c" + assert arduino8266._q("/a b/$x") == _shq("/a b/$$x") + + +def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None: + """The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags + reach assembly compiles.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSER_KNOB=1", "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert "-DUSER_KNOB=1" in asflags + assert "-Wno-volatile" not in asflags + cxxflags = next( + line for line in content.splitlines() if line.startswith("cxxflags") + ) + assert "-Wno-volatile" in cxxflags + + +def test_write_project_returns_changed(tmp_path: Path) -> None: + """The documented contract: True when build.ninja changed, False on an + identical regeneration (pins byte-stable output too).""" + paths = _make_framework(tmp_path) + _set_flags() + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + ): + assert arduino8266.write_project(paths, None) is True + assert arduino8266.write_project(paths, None) is False + + +def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None: + """A half-extracted package must fail by name at generation, not after + the full compile at the elf2bin edge.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "elf2bin.py").unlink() + _set_flags() + src = CORE.relative_src_path() + (src / "main.cpp").parent.mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + pytest.raises(EsphomeError, match="elf2bin"), + ): + arduino8266.write_project(paths, None) + + +def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None: + """A missing generated source tree is its own error, not an install one.""" + paths = _make_framework(tmp_path) + _set_flags() + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + patch.object( + arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope" + ), + pytest.raises(EsphomeError, match="source directory"), + ): + arduino8266.write_project(paths, None) + + def test_build_config_custom_mmu_without_knob_raises() -> None: """Custom MMU sizes without the CUSTOM knob are refused.""" with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): @@ -600,6 +986,49 @@ def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None: + """A spaced -D/-I user flag arrives shell-quoted; assembly must still + receive it.""" + paths = _make_framework(tmp_path) + _set_flags('-DGREETING="hello world"', "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert _shq("-DGREETING=hello world") in asflags + assert "-Wno-volatile" not in asflags + + +def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None: + """A libArchive:false library's objects reach the link directly.""" + + paths = _make_framework(tmp_path) + lib_src = tmp_path / "gdb" / "src" + lib_src.mkdir(parents=True) + (lib_src / "GDBStub.cpp").write_text("") + _set_flags() + lib = ArduinoLibrary( + name="GDBStub", + sources=[lib_src / "GDBStub.cpp"], + include_dirs=[lib_src], + lib_archive=False, + ) + content = _write_ninja(paths, libraries=[lib]) + assert "libGDBStub.a" not in content + link_line = next( + line for line in content.splitlines() if line.startswith("build firmware.elf") + ) + assert "GDBStub.cpp.o" in link_line + + +def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None: + """A caller bypassing config validation gets the board named, not a + KeyError.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board" + with pytest.raises(EsphomeError, match="'not_a_board' is not supported"): + _write_ninja(paths) + + def test_unflag_tokens_join_spaced_entries() -> None: """Spaced build_unflags entries ("-D FOO") match the joined token.""" CORE.build_unflags = {"-D FOO", "-l bar"} @@ -1072,6 +1501,31 @@ def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_unmatched_unflag_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unflag that removes nothing is named; a matching one is silent.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSE_FOO=1") + CORE.build_unflags = {"-DUSE_FOO", "-Os"} + content = _write_ninja(paths) + assert "matched no build flag: -DUSE_FOO" in caplog.text + assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0] + # The matching -Os unflag really removed the framework flag + cflags = next(line for line in content.splitlines() if line.startswith("cflags")) + assert " -Os " not in cflags + + +def test_write_project_lexes_build_flags_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A malformed build_flags entry warns once per generation.""" + paths = _make_framework(tmp_path) + _set_flags("-DFOO=1 -l") + _write_ninja(paths) + assert caplog.text.count("Ignoring trailing '-l'") == 1 + + def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: """With MMU_CUSTOM also set, the actionable fix is dropping the variant knob, not setting the knob the user already set.""" @@ -1171,6 +1625,11 @@ def test_generate_ld_scripts_unreadable_header_forces_regeneration( mock_run.assert_called_once() +def test_board_tables_are_equal() -> None: + """BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync.""" + assert set(BOARDS) == set(ESP8266_BOARD_BUILD) + + def test_bare_include_and_define_dropped( caplog: pytest.LogCaptureFixture, ) -> None: @@ -1208,3 +1667,49 @@ def test_defines_flags_honors_f_cpu_override() -> None: CORE.platformio_options = {"board_build.f_cpu": "160000000L"} defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) assert "-DF_CPU=160000000L" in defines + + +def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None: + """board_build.ldscript (filesystem reservation, corrected flash size) + replaces the board default; a path is rejected since the name resolves + via the -L search path.""" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"} + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld" + paths = _make_framework(tmp_path) + _set_flags() + content = _write_ninja(paths) + assert "-T eagle.flash.4m2m.ld" in content + CORE.platformio_options = {"board_build.ldscript": "../evil.ld"} + with pytest.raises(EsphomeError, match="bare script name"): + arduino8266._flash_ld_name("nodemcuv2") + + +def test_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None: + """build_unflags: -u would strip all seven -u tokens and leave the + operands as ld input files; refuse by name instead.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-u _printf_float"} + with pytest.raises(EsphomeError, match="cannot remove plain linker"): + _write_ninja(paths) + + +def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None: + """The board joined BOARDS late; the flash-size default (2m.ld) would + move _FS_end and the preferences sector on existing devices.""" + assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + + +def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None: + """A spaced override never reaches the link line: generate_ld_scripts + rejects the name first (the -T _shell_token quoting behind it is + defence-in-depth).""" + CORE.platformio_options = {"board_build.ldscript": "my script.ld"} + paths = _make_framework(tmp_path) + _set_flags() + # The completeness checks run first; give them a src dir to pass + CORE.relative_src_path().mkdir(parents=True, exist_ok=True) + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.write_project(paths, None) From 807556171bcc9b4abc3a1bc42f00898dee9eb01d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 00:00:59 +0100 Subject: [PATCH 286/316] [core][cover] Add register_apply_condition for conditions that only test the parent (#19540) --- AGENTS.md | 13 ++ esphome/automation.py | 160 +++++++++++++++------ esphome/components/cover/__init__.py | 27 ++-- esphome/components/cover/automation.h | 13 -- esphome/components/rtttl/__init__.py | 5 +- esphome/components/rtttl/rtttl.h | 6 - esphome/core/base_automation.h | 12 ++ tests/components/template/common-base.yaml | 12 +- tests/unit_tests/test_automation.py | 107 +++++++++++++- 9 files changed, 264 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf78daa5a3..113cfe1b2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -470,6 +470,19 @@ file does, and it is the authority when they disagree. The most useful starting Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. + **Conditions that only test their parent need no C++ class either.** Register them with + `register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares + against config values. + ```python + automation.register_apply_condition("my_component.is_active", schema, "is_active()") + automation.register_apply_condition( + "my_component.state_is", + schema, + automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)), + ) + ``` + `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. + * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. * **Configuration Validation:** diff --git a/esphome/automation.py b/esphome/automation.py index 373f924e4d..850ac554af 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -214,6 +214,7 @@ validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) +ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) def flash_string(config: ConfigType, value: str) -> str: @@ -255,6 +256,13 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def members(self) -> list[tuple[Any, Any, Any]]: + """Each arg as ``(conf_key, type_, const_fn or None)``.""" + return [ + (arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in self.args + ] + @dataclass(frozen=True) class ApplyField: @@ -322,6 +330,58 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +async def _apply_parent(config: ConfigType) -> str: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + return f"::{await cg.get_variable(config[CONF_ID])}" + + +def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: + # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function + # pointer conversion. + return [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + + +async def _render_values( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str, + lambda_args: TemplateArgsType, + compare: bool = False, +) -> list[str]: + """Render the argument text of one statement; every key must be present. + + ``compare``: an inlined lambda expression is parenthesized so it binds as a whole + beside an operator. + """ + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda(value, lambda_args, return_type=type_) + expr = call_lambda(inner) + bare = compare and isinstance(expr, cg.RawExpression) + exprs.append(f"({expr})" if bare else str(expr)) + elif const_fn is not None: + exprs.append(const_fn(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + return exprs + + +def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> list[Any]: + return [_config_lookup(config, key) for key, _, _ in members] + + def register_apply_action( name: str, schema: cv.Schema, @@ -334,10 +394,14 @@ def register_apply_action( in, lambdas are called inline with the trigger args. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. """ + # An action stores the value, so a std::string constant stays in flash on ESP8266. statements_spec = [ ( c.target, - [(arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in c.args], + [ + (key, t, fn or (flash_string if t is cg.std_string else None)) + for key, t, fn in c.members + ], ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -351,37 +415,17 @@ def register_apply_action( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - # Global-scope qualified so a trigger arg named like the id cannot shadow it. - parent = f"::{await cg.get_variable(config[CONF_ID])}" - # Must match ApplyAction::ApplyFn exactly for the function pointer conversion. - lambda_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) - for t, arg in args - ] + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] for target, members in statements_spec: - values = [_config_lookup(config, key) for key, _, _ in members] + values = _apply_values(config, members) if members and all(value is None for value in values): continue - if any(value is None for value in values): - keys = [key for key, _, _ in members] - raise EsphomeError(f"{name}: {target!r} needs all of {keys}") - exprs: list[str] = [] - for (_, type_, const_fn), value in zip(members, values, strict=True): - if isinstance(value, Lambda): - if isinstance(type_, str): - type_ = cg.RawExpression(type_.format(parent=parent)) - inner = await cg.process_lambda( - value, lambda_args, return_type=type_ - ) - exprs.append(str(call_lambda(inner))) - elif const_fn is not None: - exprs.append(const_fn(config, value)) - elif type_ is cg.std_string: - exprs.append(flash_string(config, value)) - else: - exprs.append(str(cg.safe_exp(value))) + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args + ) statements.append(f"{receiver}{target.format(*exprs)};") if call: statements = [ @@ -397,6 +441,51 @@ def register_apply_action( register_action(name, ApplyAction, schema, synchronous=True)(builder) +def register_apply_condition( + name: str, schema: cv.Schema, check: str | ApplyCall +) -> None: + """Register a condition that is one expression on its parent, with no C++ class. + + ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an + ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares + against config values, all of which must be present. Write ``== false`` to negate. + String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + Generates one stateless function for ``ApplyCondition``. + """ + call = check if isinstance(check, ApplyCall) else ApplyCall(check) + members = call.members + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) + exprs = await _render_values( + name, + call.target, + members, + _apply_values(config, members), + config, + parent, + lambda_args, + compare=True, + ) + check_lambda = LambdaExpression( + [f"return {parent}->{call.target.format(*exprs)};"], + lambda_args, + capture="", + return_type=cg.bool_, + ) + return cg.new_Pvariable(condition_id, template_arg, check_lambda) + + register_condition(name, ApplyCondition, schema)(builder) + + def validate_potentially_and_condition(value): if isinstance(value, list): with cv.remove_prepend_path(["and"]): @@ -643,28 +732,15 @@ async def for_condition_to_code( return var -@register_condition( +register_apply_condition( "component.is_idle", - LambdaCondition, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(cg.Component), } ), + "is_idle()", ) -async def component_is_idle_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - lambda_ = await cg.process_lambda( - Lambda(f"return {comp}->is_idle();"), args, return_type=bool - ) - return new_lambda_pvariable( - condition_id, lambda_, StatelessLambdaCondition, template_arg - ) @register_action( diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index f7e029ad9e..0f5aa9b450 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,7 +1,7 @@ import logging from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -36,7 +36,7 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -44,7 +44,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, SafeExpType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -87,8 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) -CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( "CoverOpenedTrigger", automation.Trigger.template() ) @@ -287,19 +285,12 @@ COVER_CONDITION_SCHEMA = cv.maybe_simple_value( ) -async def cover_condition_to_code( - config: ConfigType, condition_id: ID, template_arg: MockObj, args: TemplateArgsType -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) - - -automation.register_condition( - "cover.is_open", CoverIsOpenCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) -automation.register_condition( - "cover.is_closed", CoverIsClosedCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) +automation.register_apply_condition( + "cover.is_open", COVER_CONDITION_SCHEMA, f"position == {COVER_OPEN}" +) +automation.register_apply_condition( + "cover.is_closed", COVER_CONDITION_SCHEMA, f"position == {COVER_CLOSED}" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 9af4a1eee5..2032eba0ec 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,19 +6,6 @@ namespace esphome::cover { -template class CoverPositionCondition final : public Condition { - public: - CoverPositionCondition(Cover *cover) : cover_(cover) {} - - bool check(const Ts &...x) override { return this->cover_->position == (OPEN ? COVER_OPEN : COVER_CLOSED); } - - protected: - Cover *cover_; -}; - -template using CoverIsOpenCondition = CoverPositionCondition; -template using CoverIsClosedCondition = CoverPositionCondition; - template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4da64a8304..4f4d17b441 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -18,7 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -120,12 +119,12 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "rtttl.is_playing", - IsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), + "is_playing()", ) diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 0bf2fce70f..7a32b79b17 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -116,9 +115,4 @@ class Rtttl final : public Component { #endif }; -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::rtttl diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index b0c4b64995..703c7b40e3 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -267,6 +267,18 @@ template class ApplyAction final : public Action { ApplyFn apply_; }; +/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. +template class ApplyCondition final : public Condition { + public: + using CheckFn = bool (*)(const std::remove_cvref_t &...); + explicit ApplyCondition(CheckFn check) : check_(check) {} + + bool check(const Ts &...x) override { return this->check_(x...); } + + protected: + CheckFn check_; +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 81b13de7bf..ccef9ded05 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -551,11 +551,17 @@ text: update_interval: 1s lambda: | return std::string{"Hello!"}; + # Exercise a register_apply_condition inside a trigger with a std::string + # argument, so ApplyCondition is compiled. set_action: then: - - logger.log: - format: Template Text set to %s - args: ["x.c_str()"] + - if: + condition: + cover.is_open: template_cover_with_triggers + then: + - logger.log: + format: Template Text set to %s + args: ["x.c_str()"] alarm_control_panel: - platform: template diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c7c5ae998e..c4265a7888 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -10,6 +10,7 @@ import pytest from esphome.automation import ( ApplyAction, ApplyCall, + ApplyCondition, ApplyField, CallbackAutomation, TriggerForwarder, @@ -19,6 +20,7 @@ from esphome.automation import ( has_non_synchronous_actions, maybe_simple_id, register_apply_action, + register_apply_condition, register_bare_action, register_bare_condition, register_parented_action, @@ -602,6 +604,20 @@ def test_shared_builders_keep_synchronous_flag( assert actions["my.parented"].synchronous is synchronous +async def _run_entry( + entry: RegistryEntry, + config: dict[str, object], + args: list[tuple[object, str]] | None, + platform: str, +) -> RegistryEntry: + """Run a registered builder with the given config, trigger args and platform.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + async def _run_apply_action( registries: tuple[Registry, Registry], fields: tuple[ApplyField | ApplyCall, ...], @@ -611,14 +627,22 @@ async def _run_apply_action( platform: str = "esp32", ) -> RegistryEntry: """Register an apply action and run its builder with the given config.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} actions, _ = registries register_apply_action("my.apply", None, *fields, call=call) - entry = actions["my.apply"] - args = args or [] - template_arg = cg.TemplateArguments(*(t for t, _ in args)) - await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) - return entry + return await _run_entry(actions["my.apply"], config, args, platform) + + +async def _run_apply_condition( + registries: tuple[Registry, Registry], + check: str | ApplyCall, + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + platform: str = "esp32", +) -> RegistryEntry: + """Register an apply condition and run its builder with the given config.""" + _, conditions = registries + register_apply_condition("my.check", None, check) + return await _run_entry(conditions["my.check"], config, args, platform) def _apply_lambda(mock_cg: MockCodegen) -> str: @@ -774,6 +798,13 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) with pytest.raises(ValueError, match="'kd' is not in the schema"): register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + register_apply_condition( + "my.is", schema, ApplyCall("kp == {}", (("kp", cg.float_),)) + ) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_condition( + "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) + ) either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) for wrapped in ( @@ -805,3 +836,67 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( mock_cg ) + + +@pytest.mark.asyncio +async def test_register_apply_condition_predicate( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_condition( + registries, "is_playing()", {}, args=[(cg.int32, "x")] + ) + assert entry.type_id is ApplyCondition + condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args + assert condition_id == ID("obj_1") + assert str(template_arg) == "" + text = str(check) + assert text.startswith("[](const std::remove_cvref_t & x) -> bool {") + assert f"return ::{PARENT_OBJ}->is_playing();" in text + + +@pytest.mark.asyncio +async def test_apply_condition_compares_config_value( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + check = ApplyCall("state == {}", (("state", cg.bool_),)) + await _run_apply_condition(registries, check, {"state": True}) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_condition(registries, check, {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_condition_string_constant_is_a_plain_literal( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "expected", "called"), + [ + ("return x;", "->state == (x);", False), + ('return x.empty() ? "e" : x;', '->state == (x.empty() ? "e" : x);', False), + ('if (x.empty()) return "e";\nreturn x;', "}(x);", True), + ], +) +async def test_apply_condition_string_lambda_paths( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + body: str, + expected: str, + called: bool, +) -> None: + """A single return is inlined with no copy; a longer body is a called std::string lambda.""" + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition( + registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] + ) + text = _apply_lambda(mock_cg) + assert expected in text + assert ("-> std::string {" in text) is called From e1126a152e2fda099f10ec9b99c89ee41ffacc70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 00:30:54 +0100 Subject: [PATCH 287/316] [text_sensor] Use register_apply_condition for text_sensor.state (#19541) --- esphome/components/text_sensor/__init__.py | 14 ++------------ esphome/components/text_sensor/automation.h | 12 ------------ tests/components/text_sensor/common.yaml | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 35e33f237c..34c46d5451 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -43,10 +43,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorStateCondition = text_sensor_ns.class_( - "TextSensorStateCondition", automation.Condition -) - FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) @@ -240,22 +236,16 @@ async def to_code(config): cg.add_global(text_sensor_ns.using) -@automation.register_condition( +automation.register_apply_condition( "text_sensor.state", - TextSensorStateCondition, cv.Schema( { cv.Required(CONF_ID): cv.use_id(TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), + automation.ApplyCall("state == {}", ((CONF_STATE, cg.std_string),)), ) -async def text_sensor_state_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(templ)) - return var FILTER_SOURCE_FILES = filter_source_files_from_defines( diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ac130afa33..ae632753e5 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -22,16 +22,4 @@ class TextSensorStateRawTrigger final : public Trigger { } }; -template class TextSensorStateCondition final : public Condition { - public: - explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(std::string, state) - - bool check(const Ts &...x) override { return this->parent_->state == this->state_.value(x...); } - - protected: - TextSensor *parent_; -}; - } // namespace esphome::text_sensor diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index ce1dd3a3f2..efbf4e1dc7 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -32,6 +32,23 @@ text_sensor: filters: - map: - ON -> Active + # text_sensor.state with a constant and with a lambda, inside a trigger + # whose argument is a std::string. + on_value: + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: Error + then: + - logger.log: substitute reports an error + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: !lambda return x; + then: + - logger.log: substitute matches the mapped value - platform: template name: "Test Map Multiple" From 698eb71862ba12abae74a6014614330ffaae6395 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 10:03:33 +0100 Subject: [PATCH 288/316] [core] Add ESPHOME_strlen_P to the progmem helpers (#19574) --- esphome/components/noise/noise.cpp | 20 ++++---------------- esphome/core/progmem.h | 2 ++ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 4806706167..86ea1ec874 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -2,16 +2,13 @@ #ifdef USE_NOISE #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::noise { static const char *const TAG = "noise"; @@ -74,22 +71,13 @@ size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *rea return 0; } buf[0] = HANDSHAKE_STATUS_REJECT; -#ifdef USE_STORE_LOG_STR_IN_FLASH - // On ESP8266 with flash strings, we need to use PROGMEM-aware functions - size_t reason_len = strlen_P(reinterpret_cast(reason)); - reason_len = std::min(reason_len, capacity - 1); - if (reason_len > 0) { - memcpy_P(buf + 1, reinterpret_cast(reason), reason_len); - } -#else + // The reason may live in PROGMEM on ESP8266; the progmem helpers read RAM and flash alike const char *reason_str = LOG_STR_ARG(reason); - size_t reason_len = strlen(reason_str); - reason_len = std::min(reason_len, capacity - 1); + size_t reason_len = std::min(ESPHOME_strlen_P(reason_str), capacity - 1); if (reason_len > 0) { // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string - std::memcpy(buf + 1, reason_str, reason_len); + progmem_memcpy(buf + 1, reason_str, reason_len); } -#endif return reason_len + 1; } diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 992b3c0e92..28440fe838 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -24,6 +24,7 @@ #define ESPHOME_strcasecmp_P strcasecmp_P #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P +#define ESPHOME_strlen_P strlen_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; // Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of @@ -47,6 +48,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strcasecmp_P strcasecmp #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp +#define ESPHOME_strlen_P strlen // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. From cd5ff787f47e35d739997e0a1154148f2d896f57 Mon Sep 17 00:00:00 2001 From: Philip Poten Date: Thu, 24 Sep 2026 11:18:32 +0200 Subject: [PATCH 289/316] [lvgl] Allow negative text_letter_space, text_line_space (#19576) --- esphome/components/lvgl/schemas.py | 4 ++-- tests/components/lvgl/lvgl-package.yaml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bbc977dca5..a546d9f183 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -292,8 +292,8 @@ BASE_PROPS = { "LV_TEXT_DECOR_", "NONE", "UNDERLINE", "STRIKETHROUGH" ).several_of, "text_font": lv_font, - "text_letter_space": lvalid.lv_positive_int, - "text_line_space": lvalid.lv_positive_int, + "text_letter_space": lvalid.lv_int, + "text_line_space": lvalid.lv_int, "text_opa": lvalid.opacity, "text_outline_stroke_color": lvalid.lv_color, "text_outline_stroke_opa": lvalid.opacity, diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index bd2e77ee8c..e10ca68ac6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -560,6 +560,8 @@ lvgl: #endif return std::string(buf); align: top_left + text_letter_space: -4 + text_line_space: -4 - container: align: center anim_duration: 1s From 64127632f5aef589966e66f7538940860ca71e90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:11:50 +0100 Subject: [PATCH 290/316] [core][logger] Keep ESP8266 log strings in flash and deprecate the RAM option (#19577) --- esphome/components/logger/__init__.py | 21 ++++++++++++--- esphome/components/logger/log_buffer.h | 10 +++---- esphome/components/logger/logger.cpp | 7 +++-- esphome/components/logger/logger.h | 16 +++++------ esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_sensor.cpp | 2 +- .../prometheus/prometheus_handler.cpp | 6 ++++- esphome/components/script/script.cpp | 2 +- esphome/components/script/script.h | 2 +- esphome/core/log.cpp | 2 +- esphome/core/log.h | 11 ++++---- esphome/core/progmem.h | 11 ++------ tests/component_tests/logger/test_logger.py | 27 +++++++++++++++++++ .../logger/test_logger_ram_log_strings.yaml | 9 +++++++ 14 files changed, 87 insertions(+), 41 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_ram_log_strings.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 138db75ad1..eb1f34565a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,3 +1,4 @@ +import logging import re from typing import Any @@ -63,6 +64,8 @@ from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] logger_ns = cg.esphome_ns.namespace("logger") LOG_LEVELS = { @@ -105,6 +108,7 @@ DEFAULT = "DEFAULT" CONF_INITIAL_LEVEL = "initial_level" CONF_LOGGER_ID = "logger_id" +CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONF_RUNTIME_TAG_LEVELS = "runtime_tag_levels" CONF_TASK_LOG_BUFFER_SIZE = "task_log_buffer_size" CONF_WAIT_FOR_CDC = "wait_for_cdc" @@ -219,6 +223,18 @@ def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: return config +def warn_ram_log_strings(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH) is False: + _LOGGER.warning( + "'%s: false' is ignored and will be rejected in 2027.4.0. Log format strings " + "always stay in flash now; copying them into RAM gave no speed gain and the " + "lost RAM caused crashes. Remove the option", + CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH, + ) + return config + + 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") @@ -232,7 +248,6 @@ LoggerMessageTrigger = logger_ns.class_( ) -CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -332,6 +347,7 @@ CONFIG_SCHEMA = cv.All( validate_local_no_higher_than_global, validate_initial_no_higher_than_global, validate_wait_for_cdc, + warn_ram_log_strings, ) @@ -450,9 +466,6 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_build_flag("-DCORE_DEBUG_LEVEL=5") if CORE.is_esp32 and is_at_least_very_verbose: cg.add_build_flag("-DENABLE_I2C_DEBUG_BUFFER") - if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH): - cg.add_build_flag("-DUSE_STORE_LOG_STR_IN_FLASH") - if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) diff --git a/esphome/components/logger/log_buffer.h b/esphome/components/logger/log_buffer.h index 067ce04114..14121d3dd9 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -105,9 +105,9 @@ struct LogBuffer { this->format_vsnprintf_(format, args); this->finalize_(); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void HOT format_body_P(PGM_P format, va_list args) { - this->format_vsnprintf_P_(format, args); +#ifdef USE_ESP8266 + void HOT format_body_p(PGM_P format, va_list args) { + this->format_vsnprintf_p_(format, args); this->finalize_(); } #endif @@ -158,8 +158,8 @@ struct LogBuffer { return; this->process_vsnprintf_result_(vsnprintf(this->current_(), this->remaining_(), format, args)); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void format_vsnprintf_P_(PGM_P format, va_list args) { +#ifdef USE_ESP8266 + void format_vsnprintf_p_(PGM_P format, va_list args) { if (this->full_()) return; this->process_vsnprintf_result_(vsnprintf_P(this->current_(), this->remaining_(), format, args)); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index bfc005070e..757ba6771e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -127,9 +127,8 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch } #endif // USE_ESP32 || USE_HOST || USE_LIBRETINY || USE_ZEPHYR -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Implementation for ESP8266 with flash string support. -// Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. +#ifdef USE_ESP8266 +// ESP8266 keeps log format strings in flash. // // This function handles format strings stored in flash memory (PROGMEM) to save RAM. // Uses vsnprintf_P to read the format string directly from flash without copying to RAM. @@ -141,7 +140,7 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args, nullptr); } -#endif // USE_STORE_LOG_STR_IN_FLASH +#endif // USE_ESP8266 inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index ae55f4145a..6bdd5910f6 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -200,9 +200,9 @@ class Logger final : public Component { float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH - void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, - va_list args); // NOLINT +#ifdef USE_ESP8266 + // NOLINTNEXTLINE(readability-identifier-naming) + void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args); #endif protected: @@ -244,14 +244,14 @@ class Logger final : public Component { buf.format_body(format, args); } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 // Format a log message with flash string format and write it to a buffer with header, footer, and null terminator // ESP8266-only (single-task), thread_name is always nullptr - inline void HOT format_log_to_buffer_with_terminator_P_(uint8_t level, const char *tag, int line, + inline void HOT format_log_to_buffer_with_terminator_p_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args, LogBuffer &buf) { buf.write_header(level, tag, line, nullptr); - buf.format_body_P(reinterpret_cast(format), args); + buf.format_body_p(reinterpret_cast(format), args); } #endif @@ -283,9 +283,9 @@ class Logger final : public Component { FormatType format, va_list args, const char *thread_name) { RecursionGuard guard(recursion_guard); LogBuffer buf{this->tx_buffer_, ESPHOME_LOGGER_TX_BUFFER_SIZE}; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 if constexpr (std::is_same_v) { - this->format_log_to_buffer_with_terminator_P_(level, tag, line, format, args, buf); + this->format_log_to_buffer_with_terminator_p_(level, tag, line, format, args, buf); } else #endif { diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 7920187f92..d555358573 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -50,7 +50,7 @@ bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 char buf[LOCK_STATE_STR_SIZE]; strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 1c0625d1c9..0fa30ef937 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -59,7 +59,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); #else root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 0412d8a842..26b7c6ea80 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -903,7 +903,7 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); #else stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); @@ -947,7 +947,11 @@ void PrometheusHandler::climate_setting_row_(AsyncResponseStream *stream, climat stream->print(ESPHOME_F("\",category=\"")); stream->print(setting.c_str()); stream->print(ESPHOME_F("\",setting_value=\"")); +#ifdef USE_ESP8266 + stream->print((const __FlashStringHelper *) setting_value); +#else stream->print(LOG_STR_ARG(setting_value)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 61bca5bc28..6a35684da8 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -5,7 +5,7 @@ namespace esphome::script { static const char *const TAG = "script"; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 63d0ff7cb3..5e66899953 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -13,7 +13,7 @@ namespace esphome::script { class ScriptLogger { protected: -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 9fcddfeff6..2f1d568758 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -36,7 +36,7 @@ void HOT esp_log_printf_(int level, const char *tag, int line, const char *forma #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void HOT esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...) { #ifdef USE_LOGGER ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); diff --git a/esphome/core/log.h b/esphome/core/log.h index 14d24412ef..362ed83b91 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -16,7 +16,7 @@ #include #include -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include "WString.h" #endif @@ -63,7 +63,8 @@ namespace esphome { void esp_log_printf_(int level, const char *tag, int line, const char *format, ...) // NOLINT __attribute__((format(printf, 4, 5))); -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 +// NOLINTNEXTLINE(readability-identifier-naming) void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT @@ -71,7 +72,7 @@ void esp_log_vprintf_(int level, const char *tag, int line, const char *format, int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #define ESPHOME_LOG_FORMAT(format) F(format) #else #define ESPHOME_LOG_FORMAT(format) format @@ -172,7 +173,7 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT // Helper class that identifies strings that may be stored in flash storage (similar to Arduino's __FlashStringHelper) struct LogString; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include @@ -181,7 +182,7 @@ struct LogString; #define LOG_STR(s) (reinterpret_cast(PSTR(s))) #define LOG_STR_LITERAL(s) LOG_STR_ARG(LOG_STR(s)) -#else // !USE_STORE_LOG_STR_IN_FLASH +#else // !USE_ESP8266 #define LOG_STR(s) (reinterpret_cast(s)) #define LOG_STR_ARG(s) (reinterpret_cast(s)) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 28440fe838..54fc42dde1 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -27,17 +27,10 @@ #define ESPHOME_strlen_P strlen_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; -// Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of -// LOG_STR_ARG: when LOG_STR_ARG treats the LogString as PROGMEM (PGM_P), the -// table data must actually be in flash; when LOG_STR_ARG treats it as a plain -// const char* (assumes RAM), the table data must live in RAM or non-logger -// consumers (ArduinoJson, Print, MQTT publish) crash on unaligned flash reads. -#ifdef USE_STORE_LOG_STR_IN_FLASH +// Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P +// on ESP8266, so the table data must be in flash to match. #define ESPHOME_PROGMEM_STRING_TABLE_STORAGE PROGMEM #else -#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE -#endif -#else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * #define ESPHOME_PSTR(s) (s) diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 4ce30afb94..199d67ff5c 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -1,7 +1,13 @@ """Tests for the logger component.""" +from collections.abc import Callable +from pathlib import Path import re +import pytest + +from esphome.core import CORE + def test_logger_pre_setup_before_other_components(generate_main): """Logger::pre_setup() must be called before any other component is created. @@ -84,3 +90,24 @@ def test_libretiny_uart0_is_emitted(generate_main): ) assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp + + +def test_ram_log_strings_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`false` is accepted but ignored: no RAM build flag, and a warning with the removal version.""" + generate_main("tests/component_tests/logger/test_logger_ram_log_strings.yaml") + + assert not any("STORE_LOG_STR" in flag for flag in CORE.build_flags) + assert "esp8266_store_log_strings_in_flash: false' is ignored" in caplog.text + assert "2027.4.0" in caplog.text + + +def test_flash_log_strings_default_does_not_warn( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "esp8266_store_log_strings_in_flash" not in caplog.text diff --git a/tests/component_tests/logger/test_logger_ram_log_strings.yaml b/tests/component_tests/logger/test_logger_ram_log_strings.yaml new file mode 100644 index 0000000000..ef6a8e2da1 --- /dev/null +++ b/tests/component_tests/logger/test_logger_ram_log_strings.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + esp8266_store_log_strings_in_flash: false From 4cbced407ddf12252895161a9d58e3b36bc7d835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:49:40 +0100 Subject: [PATCH 291/316] [ha-addon] Announce the MCP server to Home Assistant (#19413) --- .../etc/s6-overlay/s6-rc.d/discovery/run | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index bb36cfcdb4..5b0ff67909 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -4,7 +4,6 @@ # Home Assistant Add-on: ESPHome # Sends discovery information to Home Assistant. # ============================================================================== -declare config declare port # We only disable it when disabled explicitly @@ -19,14 +18,17 @@ port=$(bashio::addon.ingress_port) # Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 -config=$(\ - bashio::var.json \ - host "127.0.0.1" \ - port "^${port}" \ -) +# Send one discovery message; the config is a JSON string built with bashio::var.json. +send_discovery() { + local service=$1 + local config=$2 + if bashio::discovery "${service}" "${config}" > /dev/null; then + bashio::log.info "Successfully send ${service} discovery information to Home Assistant." + else + bashio::log.error "${service} discovery message to Home Assistant failed!" + fi +} -if bashio::discovery "esphome" "${config}" > /dev/null; then - bashio::log.info "Successfully send discovery information to Home Assistant." -else - bashio::log.error "Discovery message to Home Assistant failed!" -fi +send_discovery "esphome" "$(bashio::var.json host "127.0.0.1" port "^${port}")" +# The Device Builder MCP server, consumed by Home Assistant's mcp integration. +send_discovery "mcp" "$(bashio::var.json url "http://127.0.0.1:${port}/api/mcp")" From f24e1f31f2ba9f3e30d71237832ba05b9a3ff9d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:50:33 +0100 Subject: [PATCH 292/316] [rotary_encoder] Use register_apply_action for the set_value action (#19545) --- .../rotary_encoder/rotary_encoder.h | 12 ----------- esphome/components/rotary_encoder/sensor.py | 21 ++----------------- tests/components/rotary_encoder/common.yaml | 2 +- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 286267baed..0d2f069c42 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" namespace esphome::rotary_encoder { @@ -106,15 +105,4 @@ class RotaryEncoderSensor final : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction final : public Action { - public: - RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} - TEMPLATABLE_VALUE(int, value) - - void play(const Ts &...x) override { this->encoder_->set_value(this->value_.value(x...)); } - - protected: - RotaryEncoderSensor *encoder_; -}; - } // namespace esphome::rotary_encoder diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 72722ec4b1..ab5442e676 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,8 +15,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -42,9 +40,6 @@ CONF_PUBLISH_INITIAL_VALUE = "publish_initial_value" RotaryEncoderSensor = rotary_encoder_ns.class_( "RotaryEncoderSensor", sensor.Sensor, cg.Component ) -RotaryEncoderSetValueAction = rotary_encoder_ns.class_( - "RotaryEncoderSetValueAction", automation.Action -) def validate_min_max_value(config: ConfigType) -> ConfigType: @@ -118,25 +113,13 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "sensor.rotary_encoder.set_value", - RotaryEncoderSetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor), cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.int_), ) -async def sensor_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) - cg.add(var.set_value(template_)) - return var diff --git a/tests/components/rotary_encoder/common.yaml b/tests/components/rotary_encoder/common.yaml index 57ffdab817..668cfac0e2 100644 --- a/tests/components/rotary_encoder/common.yaml +++ b/tests/components/rotary_encoder/common.yaml @@ -18,7 +18,7 @@ sensor: value: 10 - sensor.rotary_encoder.set_value: id: rotary_encoder1 - value: !lambda "return -1;" + value: !lambda "return -static_cast(x);" on_clockwise: - logger.log: Clockwise on_anticlockwise: From eae8ff7f911afa46058281353dc42dc086147456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:51:04 +0100 Subject: [PATCH 293/316] [audio_dac] Use register_apply_action for the mute_off, mute_on and set_volume actions (#19546) --- esphome/components/audio_dac/__init__.py | 49 +++++------------------ esphome/components/audio_dac/automation.h | 41 ------------------- tests/components/aic3204/common.yaml | 3 ++ 3 files changed, 14 insertions(+), 79 deletions(-) delete mode 100644 esphome/components/audio_dac/automation.h diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 1351793afd..fbaa72014f 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -2,9 +2,8 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_VOLUME +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -13,10 +12,6 @@ IS_PLATFORM_COMPONENT = True audio_dac_ns = cg.esphome_ns.namespace("audio_dac") AudioDac = audio_dac_ns.class_("AudioDac") -MuteOffAction = audio_dac_ns.class_("MuteOffAction", automation.Action) -MuteOnAction = audio_dac_ns.class_("MuteOnAction", automation.Action) -SetVolumeAction = audio_dac_ns.class_("SetVolumeAction", automation.Action) - MUTE_ACTION_SCHEMA = maybe_simple_id( { @@ -33,41 +28,19 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( - "audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True -) -async def audio_dac_mute_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("audio_dac.mute_off", "set_mute_off()"), + ("audio_dac.mute_on", "set_mute_on()"), +): + automation.register_apply_action( + _name, MUTE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "audio_dac.set_volume", - SetVolumeAction, SET_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def audio_dac_set_volume_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_VOLUME), args, cg.float_) - cg.add(var.set_volume(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h deleted file mode 100644 index 9c5348271c..0000000000 --- a/esphome/components/audio_dac/automation.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_dac.h" - -namespace esphome::audio_dac { - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_off(); } - - protected: - AudioDac *audio_dac_; -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_on(); } - - protected: - AudioDac *audio_dac_; -}; - -template class SetVolumeAction final : public Action { - public: - explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - TEMPLATABLE_VALUE(float, volume) - - void play(const Ts &...x) override { this->audio_dac_->set_volume(this->volume_.value(x...)); } - - protected: - AudioDac *audio_dac_; -}; - -} // namespace esphome::audio_dac diff --git a/tests/components/aic3204/common.yaml b/tests/components/aic3204/common.yaml index 5f175faee3..8cb10b24f8 100644 --- a/tests/components/aic3204/common.yaml +++ b/tests/components/aic3204/common.yaml @@ -5,7 +5,10 @@ esphome: - audio_dac.mute_on: - audio_dac.set_volume: volume: 50% + - audio_dac.set_volume: + volume: !lambda return id(aic3204_dac).volume() * 0.5f; audio_dac: - platform: aic3204 + id: aic3204_dac i2c_id: i2c_bus From e98c4bd375879c4aee50fce7d50483abf2ea4b91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:51:30 +0100 Subject: [PATCH 294/316] [online_image] Use register_apply_action for the set_url and release actions (#19547) --- esphome/components/online_image/image.py | 44 +++++-------------- .../components/online_image/online_image.h | 30 +++---------- tests/components/online_image/common.yaml | 11 +++++ 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index 3e9517937e..a36f3a9494 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -14,8 +14,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -31,14 +30,6 @@ OnlineImage = online_image_ns.class_( "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) - ONLINE_IMAGE_SCHEMA = ( runtime_image.runtime_image_schema(OnlineImage) .extend( @@ -90,31 +81,18 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +automation.register_apply_action( + "online_image.set_url", + SET_URL_SCHEMA, + automation.ApplyField(CONF_URL, "set_url", cg.std_string), + automation.ApplyField(CONF_UPDATE, "update_if", cg.bool_), ) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var +automation.register_apply_action( + "online_image.release", + RELEASE_IMAGE_SCHEMA, + automation.ApplyCall("release()"), +) _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 3e386f8cc8..8b3485b8f7 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -43,6 +43,11 @@ class OnlineImage final : public PollingComponent, bool is_big_endian = false); void update() override; + /** Download now when `update` is true (the `update` flag of `online_image.set_url`). */ + void update_if(bool update) { + if (update) + this->update(); + } void loop() override; /** Set the URL to download the image from. */ @@ -104,29 +109,4 @@ class OnlineImage final : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction final : public Action { - public: - OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, url) - TEMPLATABLE_VALUE(bool, update) - void play(const Ts &...x) override { - this->parent_->set_url(this->url_.value(x...)); - if (this->update_.value(x...)) { - this->parent_->update(); - } - } - - protected: - OnlineImage *parent_; -}; - -template class OnlineImageReleaseAction final : public Action { - public: - OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->release(); } - - protected: - OnlineImage *parent_; -}; - } // namespace esphome::online_image diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index d8d04850cf..df8f85596f 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -84,3 +84,14 @@ esphome: id: online_rgba_image url: !lambda |- return str_sprintf("http://homeassistant.local:8123"); + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: false + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: !lambda return id(online_rgba_image).get_width() > 0; + - online_image.release: online_rgba_image + - online_image.release: + id: online_rgb24_image From 4477e35d66878bbafa0847d1a0b5e6d4eaff9dda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:52:34 +0100 Subject: [PATCH 295/316] [dfplayer] Use register_apply_action for the play, volume, eq, device and transport actions (#19549) --- esphome/components/dfplayer/__init__.py | 285 ++++-------------------- esphome/components/dfplayer/dfplayer.h | 99 +------- tests/components/dfplayer/common.yaml | 12 + 3 files changed, 57 insertions(+), 339 deletions(-) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index afe8c72675..d2120deaa6 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -37,23 +37,6 @@ DEVICE = { "TF_CARD": Device.TF_CARD, } -NextAction = dfplayer_ns.class_("NextAction", automation.Action) -PreviousAction = dfplayer_ns.class_("PreviousAction", automation.Action) -PlayMp3Action = dfplayer_ns.class_("PlayMp3Action", automation.Action) -PlayFileAction = dfplayer_ns.class_("PlayFileAction", automation.Action) -PlayFolderAction = dfplayer_ns.class_("PlayFolderAction", automation.Action) -SetVolumeAction = dfplayer_ns.class_("SetVolumeAction", automation.Action) -VolumeUpAction = dfplayer_ns.class_("VolumeUpAction", automation.Action) -VolumeDownAction = dfplayer_ns.class_("VolumeDownAction", automation.Action) -SetEqAction = dfplayer_ns.class_("SetEqAction", automation.Action) -SleepAction = dfplayer_ns.class_("SleepAction", automation.Action) -ResetAction = dfplayer_ns.class_("ResetAction", automation.Action) -StartAction = dfplayer_ns.class_("StartAction", automation.Action) -PauseAction = dfplayer_ns.class_("PauseAction", automation.Action) -StopAction = dfplayer_ns.class_("StopAction", automation.Action) -RandomAction = dfplayer_ns.class_("RandomAction", automation.Action) -SetDeviceAction = dfplayer_ns.class_("SetDeviceAction", automation.Action) - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -87,41 +70,30 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "dfplayer.play_next", - NextAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, +DFPLAYER_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(DFPlayer), + } ) -async def dfplayer_next_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("dfplayer.play_next", "next()"), + ("dfplayer.play_previous", "previous()"), + ("dfplayer.volume_up", "volume_up()"), + ("dfplayer.volume_down", "volume_down()"), + ("dfplayer.sleep", "sleep()"), + ("dfplayer.reset", "reset()"), + ("dfplayer.start", "start()"), + ("dfplayer.pause", "pause()"), + ("dfplayer.stop", "stop()"), + ("dfplayer.random", "random()"), +): + automation.register_apply_action( + _name, DFPLAYER_ACTION_SCHEMA, automation.ApplyCall(_call) + ) -@automation.register_action( - "dfplayer.play_previous", - PreviousAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_previous_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_mp3", - PlayMp3Action, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -129,70 +101,43 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyField(CONF_FILE, "play_mp3", cg.uint16), ) -async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - return var - -@automation.register_action( +# loop and file default to what the old action's unset templatable values evaluated to +automation.register_apply_action( "dfplayer.play", - PlayFileAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {})", ((CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)) + ), ) -async def dfplayer_play_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_folder", - PlayFolderAction, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FOLDER): cv.templatable(cv.int_), - cv.Optional(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_FILE, default=0): cv.templatable(cv.int_), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyCall( + "play_folder({}, {}, {})", + ((CONF_FOLDER, cg.uint16), (CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)), + ), ) -async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FOLDER], args, cg.uint16) - cg.add(var.set_folder(template_)) - if CONF_FILE in config: - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_device", - SetDeviceAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -200,19 +145,11 @@ async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): }, key=CONF_DEVICE, ), - synchronous=True, + automation.ApplyField(CONF_DEVICE, "set_device", Device), ) -async def dfplayer_set_device_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_DEVICE], args, Device) - cg.add(var.set_device(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_volume", - SetVolumeAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -220,51 +157,11 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.uint8), ) -async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.uint8) - cg.add(var.set_volume(template_)) - return var - -@automation.register_action( - "dfplayer.volume_up", - VolumeUpAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.volume_down", - VolumeDownAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_eq", - SetEqAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -272,110 +169,8 @@ async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): }, key=CONF_EQ_PRESET, ), - synchronous=True, + automation.ApplyField(CONF_EQ_PRESET, "set_eq", EqPreset), ) -async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_EQ_PRESET], args, EqPreset) - cg.add(var.set_eq(template_)) - return var - - -@automation.register_action( - "dfplayer.sleep", - SleepAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_sleep_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.reset", - ResetAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.start", - StartAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_start_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.pause", - PauseAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_pause_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.stop", - StopAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.random", - RandomAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_random_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var def _default_enable(value: Any) -> Any: diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 66cf929408..ad91dcfc94 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -33,8 +33,13 @@ class DFPlayer final : public uart::UARTDevice, public Component { void play_mp3(uint16_t file); void play_file(uint16_t file); void play_file_loop(uint16_t file); + void play_file(uint16_t file, bool loop) { loop ? this->play_file_loop(file) : this->play_file(file); } void play_folder(uint16_t folder, uint16_t file); void play_folder_loop(uint16_t folder); + // The loop command plays the whole folder, so file is ignored when loop is set. + void play_folder(uint16_t folder, uint16_t file, bool loop) { + loop ? this->play_folder_loop(folder) : this->play_folder(folder, file); + } void volume_up(); void volume_down(); void set_device(Device device); @@ -72,100 +77,6 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -#define DFPLAYER_SIMPLE_ACTION(ACTION_CLASS, ACTION_METHOD) \ - template \ - class ACTION_CLASS : /* NOLINT */ \ - public Action, \ - public Parented { \ - void play(const Ts &...x) override { this->parent_->ACTION_METHOD(); } \ - }; - -DFPLAYER_SIMPLE_ACTION(NextAction, next) -DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) - -template class PlayMp3Action final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - this->parent_->play_mp3(file); - } -}; - -template class PlayFileAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_file_loop(file); - } else { - this->parent_->play_file(file); - } - } -}; - -template class PlayFolderAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, folder) - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto folder = this->folder_.value(x...); - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_folder_loop(folder); - } else { - this->parent_->play_folder(folder, file); - } - } -}; - -template class SetDeviceAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Device, device) - - void play(const Ts &...x) override { - auto device = this->device_.value(x...); - this->parent_->set_device(device); - } -}; - -template class SetVolumeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, volume) - - void play(const Ts &...x) override { - auto volume = this->volume_.value(x...); - this->parent_->set_volume(volume); - } -}; - -template class SetEqAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(EqPreset, eq) - - void play(const Ts &...x) override { - auto eq = this->eq_.value(x...); - this->parent_->set_eq(eq); - } -}; - -DFPLAYER_SIMPLE_ACTION(SleepAction, sleep) -DFPLAYER_SIMPLE_ACTION(ResetAction, reset) -DFPLAYER_SIMPLE_ACTION(StartAction, start) -DFPLAYER_SIMPLE_ACTION(PauseAction, pause) -DFPLAYER_SIMPLE_ACTION(StopAction, stop) -DFPLAYER_SIMPLE_ACTION(RandomAction, random) -DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) -DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) - template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } diff --git a/tests/components/dfplayer/common.yaml b/tests/components/dfplayer/common.yaml index 940f07a2ed..317ffb9900 100644 --- a/tests/components/dfplayer/common.yaml +++ b/tests/components/dfplayer/common.yaml @@ -1,20 +1,32 @@ esphome: on_boot: then: + - dfplayer.play_mp3: 3 + - dfplayer.play_mp3: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" - dfplayer.play: 5 - dfplayer.play: file: 4 loop: true + - dfplayer.play: + file: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.play_folder: folder: 1 file: 3 - dfplayer.play_folder: folder: 1 loop: true + - dfplayer.play_folder: + folder: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + file: !lambda "return id(dfplayer_test).is_playing() ? 4 : 3;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.set_device: device: TF_CARD + - dfplayer.set_device: USB - dfplayer.set_volume: 5 + - dfplayer.set_volume: !lambda "return id(dfplayer_test).is_playing() ? 10 : 5;" - dfplayer.set_eq: ROCK + - dfplayer.set_eq: !lambda "return id(dfplayer_test).is_playing() ? dfplayer::JAZZ : dfplayer::BASS;" - dfplayer.play_next - dfplayer.play_previous - dfplayer.reset From 0ceefd5f916c0062a553a9cab666dbee26523a21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:53:29 +0100 Subject: [PATCH 296/316] [mqtt] Use register_apply_condition for mqtt.connected (#19561) --- esphome/components/mqtt/__init__.py | 9 ++------- esphome/components/mqtt/mqtt_client.h | 9 --------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 3050ceb1a4..f4be684318 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket from esphome.components.esp32 import ( @@ -135,7 +134,6 @@ MQTTDisconnectTrigger = mqtt_ns.class_( "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) ) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) -MQTTConnectedCondition = mqtt_ns.class_("MQTTConnectedCondition", Condition) MQTTAlarmControlPanelComponent = mqtt_ns.class_( "MQTTAlarmControlPanelComponent", MQTTComponent @@ -599,18 +597,15 @@ async def register_mqtt_component(var, config): ) -@automation.register_condition( +automation.register_apply_condition( "mqtt.connected", - MQTTConnectedCondition, cv.Schema( { cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + "is_connected()", ) -async def mqtt_connected_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @automation.register_action( diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index fe0966e725..2fd9a5a550 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -417,15 +417,6 @@ template class MQTTPublishJsonAction final : public Action class MQTTConnectedCondition final : public Condition { - public: - MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->is_connected(); } - - protected: - MQTTClientComponent *parent_; -}; - template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} From 1671bb6dd35152b9a0c6d213205a56f64d28daa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:54:03 +0100 Subject: [PATCH 297/316] [time] Use register_apply_condition for time.has_time (#19560) --- esphome/components/time/__init__.py | 10 ++-------- esphome/components/time/real_time_clock.h | 9 --------- tests/components/time/common.yaml | 9 +++++++++ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index ecc448a96a..cedb9b3c9b 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -6,7 +6,6 @@ import logging import tzlocal from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_defines @@ -18,7 +17,6 @@ from esphome.const import ( CONF_DAYS_OF_WEEK, CONF_HOUR, CONF_HOURS, - CONF_ID, CONF_MINUTE, CONF_MINUTES, CONF_MONTHS, @@ -49,7 +47,6 @@ time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component) SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) -TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -469,18 +466,15 @@ async def to_code(config): cg.add_global(time_ns.using) -@automation.register_condition( +automation.register_apply_condition( "time.has_time", - TimeHasTimeCondition, cv.Schema( { cv.GenerateID(): cv.use_id(RealTimeClock), } ), + "now().is_valid()", ) -async def time_has_time_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index c449309c9f..90994d90f4 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,13 +44,4 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition final : public Condition { - public: - TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } - - protected: - RealTimeClock *parent_; -}; - } // namespace esphome::time diff --git a/tests/components/time/common.yaml b/tests/components/time/common.yaml index cd258c7aa6..776a67cc7a 100644 --- a/tests/components/time/common.yaml +++ b/tests/components/time/common.yaml @@ -12,3 +12,12 @@ time: - logger.log: "CronTrigger fired (every 10 seconds)" - platform: sntp id: sntp_time + +esphome: + on_boot: + - if: + condition: + time.has_time: + id: sntp_time + then: + - logger.log: "sntp_time has valid time" From c39ffd709970dcc80c0cb2b8156fd1b1acbf9292 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:54:42 +0100 Subject: [PATCH 298/316] [lock] Use register_apply_condition for lock.is_locked and lock.is_unlocked (#19559) --- esphome/components/lock/__init__.py | 31 ++++++++-------------------- esphome/components/lock/automation.h | 13 ------------ tests/components/lock/common.yaml | 10 +++++++++ 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a4a7b5237d..4ceb6206fd 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -34,7 +34,6 @@ LockAction = lock_ns.class_("LockAction", automation.Action) OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockCondition = lock_ns.class_("LockCondition", Condition) LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") @@ -154,26 +153,14 @@ async def lock_action_to_code( return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) +automation.register_apply_condition( + "lock.is_locked", LOCK_ACTION_SCHEMA, f"state == {LockState.LOCK_STATE_LOCKED}" +) +automation.register_apply_condition( + "lock.is_unlocked", + LOCK_ACTION_SCHEMA, + f"state == {LockState.LOCK_STATE_UNLOCKED}", +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index ec6ead79f3..13153bc1df 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -36,19 +36,6 @@ template class OpenAction final : public Action { Lock *lock_; }; -template class LockCondition final : public Condition { - public: - LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { - auto check_state = this->state_ ? LockState::LOCK_STATE_LOCKED : LockState::LOCK_STATE_UNLOCKED; - return this->parent_->state == check_state; - } - - protected: - Lock *parent_; - bool state_; -}; - /// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. template struct LockStateForwarder { diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb..2b360ce3fb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -4,6 +4,16 @@ esphome: - lock.lock: test_lock1 - lock.unlock: test_lock1 - lock.open: test_lock1 + - if: + condition: + lock.is_locked: test_lock1 + then: + - logger.log: locked + - if: + condition: + lock.is_unlocked: test_lock1 + then: + - logger.log: unlocked output: - platform: gpio From 1861d8282f37fe7fb5d10ef478281ba2a82296de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:55:12 +0100 Subject: [PATCH 299/316] [script] Use register_apply_condition for script.is_running (#19558) --- esphome/components/script/__init__.py | 9 ++------- esphome/components/script/script.h | 10 ---------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index e92850fd63..6c16d002ad 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -11,7 +11,6 @@ Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) ScriptStopAction = script_ns.class_("ScriptStopAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) -IsRunningCondition = script_ns.class_("IsRunningCondition", automation.Condition) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component) @@ -233,12 +232,8 @@ async def script_wait_action_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( +automation.register_apply_condition( "script.is_running", - IsRunningCondition, automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + "is_running()", ) -async def script_is_running_to_code(config, condition_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 5e66899953..3f988c9afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -276,16 +276,6 @@ template class ScriptStopAction final : public Action class IsRunningCondition final : public Condition { - public: - explicit IsRunningCondition(C *parent) : parent_(parent) {} - - bool check(const Ts &...x) override { return this->parent_->is_running(); } - - protected: - C *parent_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. From 6768cb818bf92bf581ce23531fdb1b3f73db6a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:55:39 +0100 Subject: [PATCH 300/316] [media_player] Use register_apply_condition for the state and muted conditions (#19557) --- esphome/components/media_player/__init__.py | 48 ++++++++------------ esphome/components/media_player/automation.h | 37 --------------- 2 files changed, 18 insertions(+), 67 deletions(-) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index ed3eaf4853..d99e7877bf 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -190,16 +190,15 @@ _STATE_TRIGGERS = ( (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), ) -# State conditions that all share the same schema and codegen handler -_STATE_CONDITIONS = [ - "idle", - "paused", - "playing", - "announcing", - "on", - "off", - "muted", -] +# State conditions: (config_key suffix, checked state) +_STATE_CONDITIONS = ( + ("idle", MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), + ("paused", MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED), + ("playing", MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), + ("announcing", MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), + ("on", MediaPlayerState.MEDIA_PLAYER_STATE_ON), + ("off", MediaPlayerState.MEDIA_PLAYER_STATE_OFF), +) MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) @@ -360,27 +359,16 @@ for _action_name in _COMMAND_ACTIONS: ) -def _snake_to_camel(name): - return "".join(word.capitalize() for word in name.split("_")) +for _condition_name, _state in _STATE_CONDITIONS: + automation.register_apply_condition( + f"media_player.is_{_condition_name}", + MEDIA_PLAYER_CONDITION_SCHEMA, + f"state == {_state}", + ) - -def _register_state_conditions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - for condition_name in _STATE_CONDITIONS: - class_name = f"Is{_snake_to_camel(condition_name)}Condition" - condition_class = media_player_ns.class_(class_name, automation.Condition) - automation.register_condition( - f"media_player.is_{condition_name}", - condition_class, - MEDIA_PLAYER_CONDITION_SCHEMA, - )(handler) - - -_register_state_conditions() +automation.register_apply_condition( + "media_player.is_muted", MEDIA_PLAYER_CONDITION_SCHEMA, "is_muted()" +) automation.register_apply_action( diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 648e9eb29f..e4bb365c82 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -27,41 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } -}; - -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } -}; - -template class IsPausedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } -}; - -template class IsAnnouncingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { - return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; - } -}; - -template class IsOnCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } -}; - -template class IsOffCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_muted(); } -}; - } // namespace esphome::media_player From e9aecb5fe8f33c3bb13ae54a85884b9057c63405 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Thu, 24 Sep 2026 09:56:11 -0300 Subject: [PATCH 301/316] [climate_ir] Store the supported modes in one mask (#19580) --- esphome/components/climate_ir/climate_ir.cpp | 18 +++-------- esphome/components/climate_ir/climate_ir.h | 32 ++++++++++++------- esphome/components/mitsubishi/mitsubishi.cpp | 2 +- esphome/components/mitsubishi/mitsubishi.h | 8 ++--- .../components/climate_ir/climate_ir_test.cpp | 29 ++++++++++++++++- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index ca639c7de0..47143e6e60 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,17 +13,7 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - if (this->supports_heat_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); + traits.set_supported_modes(this->modes_); traits.set_visual_min_temperature(this->minimum_temperature_); traits.set_visual_max_temperature(this->maximum_temperature_); @@ -98,8 +88,10 @@ void ClimateIR::dump_config() { " Supports HEAT: %s\n" " Supports COOL: %s\n" " Supports HEAT_COOL: %s", - this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_), YESNO(this->supports_heat_cool_)); + this->minimum_temperature_, this->maximum_temperature_, + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_COOL)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL))); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index d65ed5d621..060564b4ba 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -30,8 +30,10 @@ class ClimateIR : public Component, this->minimum_temperature_ = minimum_temperature; this->maximum_temperature_ = maximum_temperature; this->temperature_step_ = temperature_step; - this->supports_dry_ = supports_dry; - this->supports_fan_only_ = supports_fan_only; + if (supports_dry) + this->modes_.insert(climate::CLIMATE_MODE_DRY); + if (supports_fan_only) + this->modes_.insert(climate::CLIMATE_MODE_FAN_ONLY); this->fan_modes_ = fan_modes; this->swing_modes_ = swing_modes; this->presets_ = presets; @@ -39,9 +41,11 @@ class ClimateIR : public Component, void setup() override; void dump_config() override; - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } + void set_supports_cool(bool supports_cool) { this->set_mode_supported_(climate::CLIMATE_MODE_COOL, supports_cool); } + void set_supports_heat(bool supports_heat) { this->set_mode_supported_(climate::CLIMATE_MODE_HEAT, supports_heat); } + void set_supports_heat_cool(bool supports_heat_cool) { + this->set_mode_supported_(climate::CLIMATE_MODE_HEAT_COOL, supports_heat_cool); + } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -59,12 +63,18 @@ class ClimateIR : public Component, // Dummy implement on_receive so implementation is optional for inheritors bool on_receive(remote_base::RemoteReceiveData data) override { return false; }; - bool supports_cool_{true}; - bool supports_heat_{true}; - // Default (supports_cool && supports_heat) is resolved during code generation. - bool supports_heat_cool_{true}; - bool supports_dry_{false}; - bool supports_fan_only_{false}; + ESPHOME_ALWAYS_INLINE void set_mode_supported_(climate::ClimateMode mode, bool supported) { + if (supported) { + this->modes_.insert(mode); + } else { + this->modes_.erase(mode); + } + } + + // The HEAT_COOL default (supports_cool && supports_heat) is resolved during code generation. + static constexpr climate::ClimateModeMask DEFAULT_MODES{climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}; + climate::ClimateModeMask modes_{DEFAULT_MODES}; climate::ClimateFanModeMask fan_modes_{}; climate::ClimateSwingModeMask swing_modes_{}; climate::ClimatePresetMask presets_{}; diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index d7c4e7f1a1..9cb355fe9c 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -102,7 +102,7 @@ void MitsubishiClimate::transmit_state() { default: remote_state[6] = MITSUBISHI_MODE_COOL; remote_state[8] = MITSUBISHI_MODE_A_COOL; - if (this->supports_heat_) { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) { remote_state[6] = MITSUBISHI_MODE_HEAT; remote_state[8] = MITSUBISHI_MODE_A_HEAT; } diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 5b61beac4e..269d39b06b 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -51,10 +51,10 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}) {} - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } - void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_dry(bool supports_dry) { this->set_mode_supported_(climate::CLIMATE_MODE_DRY, supports_dry); } + void set_supports_fan_only(bool supports_fan_only) { + this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp index 479e1f1b45..25e894a360 100644 --- a/tests/components/climate_ir/climate_ir_test.cpp +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -5,7 +5,8 @@ namespace esphome::climate_ir::testing { class TestClimateIR : public ClimateIR { public: - TestClimateIR() : ClimateIR(16.0f, 30.0f) {} + explicit TestClimateIR(bool supports_dry = false, bool supports_fan_only = false) + : ClimateIR(16.0f, 30.0f, 1.0f, supports_dry, supports_fan_only) {} using ClimateIR::traits; @@ -53,4 +54,30 @@ TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); } +TEST(ClimateIRTest, DefaultModes) { + TestClimateIR climate; + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, DryAndFanOnlyFromConstructor) { + TestClimateIR climate(true, true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, SetterCanTurnAModeBackOn) { + TestClimateIR climate; + climate.set_supports_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); + climate.set_supports_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); +} + } // namespace esphome::climate_ir::testing From 22959ea1cf888b6b8a5e1b26280b7c68b7fb0955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:56:40 +0100 Subject: [PATCH 302/316] [light] Use register_apply_condition for light.is_on and light.is_off (#19556) --- esphome/components/light/automation.h | 17 -------------- esphome/components/light/automation.py | 31 +++++++++----------------- esphome/components/light/types.py | 2 -- tests/components/light/common.yaml | 11 +++++++++ 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 5ce5f1ee60..2e963479f7 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -117,23 +117,6 @@ template class LightEffectCycleAction final : publ bool include_none_{false}; }; -template class LightIsOnCondition final : public Condition { - public: - explicit LightIsOnCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; -template class LightIsOffCondition final : public Condition { - public: - explicit LightIsOffCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; - class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index d84df33a23..5ae2660774 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -38,8 +38,6 @@ from .types import ( ColorMode, DimRelativeAction, LightEffectCycleAction, - LightIsOffCondition, - LightIsOnCondition, LightState, ToggleAction, ) @@ -381,24 +379,15 @@ async def light_addressable_set_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "light.is_on", - LightIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), +LIGHT_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(LightState), + } ) -@automation.register_condition( - "light.is_off", - LightIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), + +automation.register_apply_condition( + "light.is_on", LIGHT_CONDITION_SCHEMA, "current_values.is_on()" +) +automation.register_apply_condition( + "light.is_off", LIGHT_CONDITION_SCHEMA, "current_values.is_on() == false" ) -async def light_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 4d2de314f6..ceb974f26d 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -44,8 +44,6 @@ ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) -LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) -LightIsOffCondition = light_ns.class_("LightIsOffCondition", automation.Condition) # Triggers LightTurnOnTrigger = light_ns.class_( diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5ba245e7c3..5dc7091e40 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -68,6 +68,17 @@ esphome: - light.toggle: test_binary_light - light.turn_off: test_rgb_light + - if: + condition: + light.is_on: test_binary_light + then: + - logger.log: binary light is on + - if: + condition: + light.is_off: + id: test_rgb_light + then: + - logger.log: rgb light is off - light.turn_on: id: test_rgb_light brightness: 100% From 4780936867166993317d800270a15ebab6d35268 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:07 +0100 Subject: [PATCH 303/316] [fan] Use register_apply_condition for fan.is_on and fan.is_off (#19555) --- esphome/components/fan/__init__.py | 30 ++++++++--------------------- esphome/components/fan/automation.h | 17 ---------------- tests/components/fan/common.yaml | 24 ++++++++++++++++++++--- 3 files changed, 29 insertions(+), 42 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index d53357b7c1..d00375ae96 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -83,9 +83,6 @@ FanPresetSetTrigger = fan_ns.class_( "FanPresetSetTrigger", automation.Trigger.template(cg.StringRef) ) -FanIsOnCondition = fan_ns.class_("FanIsOnCondition", automation.Condition.template()) -FanIsOffCondition = fan_ns.class_("FanIsOffCondition", automation.Condition.template()) - _FAN_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -367,27 +364,16 @@ async def fan_cycle_speed_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "fan.is_on", - FanIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), +FAN_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(Fan), + } ) -@automation.register_condition( - "fan.is_off", - FanIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), + +automation.register_apply_condition("fan.is_on", FAN_CONDITION_SCHEMA, "state") +automation.register_apply_condition( + "fan.is_off", FAN_CONDITION_SCHEMA, "state == false" ) -async def fan_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index f62a2dd8cd..5f0843581b 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -68,23 +68,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -template class FanIsOnCondition final : public Condition { - public: - explicit FanIsOnCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->state; } - - protected: - Fan *state_; -}; -template class FanIsOffCondition final : public Condition { - public: - explicit FanIsOffCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->state; } - - protected: - Fan *state_; -}; - class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 3bef83cc42..4867804394 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -96,6 +96,20 @@ button: - fan.turn_on: id: test_fan speed: !lambda 'return 1;' + - platform: template + name: "Fan Is On Off" + on_press: + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_off: test_fan + - if: + condition: + fan.is_off: + id: test_fan + then: + - fan.turn_on: test_fan # Exercise fan.turn_on inside triggers with non-empty Ts: # - number.on_value: Ts = float (Python value type; previously raised @@ -111,6 +125,10 @@ number: step: 1 on_value: then: - - fan.turn_on: - id: test_fan - speed: !lambda "return (int) x;" + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return (int) x;" From 5790181f44e669d5898726ff4dafb3fa4c20f588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:34 +0100 Subject: [PATCH 304/316] [binary_sensor] Use register_apply_condition for binary_sensor.is_on and binary_sensor.is_off (#19554) --- esphome/components/binary_sensor/__init__.py | 21 +++++-------------- esphome/components/binary_sensor/automation.h | 10 --------- tests/components/binary_sensor/common.yaml | 11 ++++++++++ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 001a055d7c..da0276c037 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,7 +1,7 @@ from logging import getLogger from esphome import automation, core -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE @@ -138,9 +138,6 @@ BinarySensorInvalidateAction = binary_sensor_ns.class_( "BinarySensorInvalidateAction", automation.Action ) -# Condition -BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Condition) - # Filters Filter = binary_sensor_ns.class_("Filter") TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter) @@ -645,20 +642,12 @@ BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( ) -@automation.register_condition( - "binary_sensor.is_on", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_on", BINARY_SENSOR_CONDITION_SCHEMA, "state" ) -async def binary_sensor_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition( - "binary_sensor.is_off", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_off", BINARY_SENSOR_CONDITION_SCHEMA, "state == false" ) -async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1c331d22bd..15a1a2c8a0 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -155,16 +155,6 @@ class StateChangeTrigger final : public Trigger, optional > } }; -template class BinarySensorCondition final : public Condition { - public: - BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - BinarySensor *parent_; - bool state_; -}; - template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index f9eda80c3f..d9939e7a14 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -43,6 +43,17 @@ binary_sensor: - binary_sensor.template.publish: id: publish_target state: !lambda "return x.value_or(false);" + - if: + condition: + binary_sensor.is_on: publish_target + then: + - logger.log: "publish_target is on" + - if: + condition: + binary_sensor.is_off: + id: publish_target + then: + - logger.log: "publish_target is off" - platform: template id: publish_target From 8b11d3da39d702f85ec774c3dc592102ed670ba9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:58 +0100 Subject: [PATCH 305/316] [switch] Use register_apply_condition for switch.is_on and switch.is_off (#19553) --- esphome/components/switch/__init__.py | 18 +++++------------- esphome/components/switch/automation.h | 10 ---------- tests/components/switch/common.yaml | 10 ++++++++++ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index e9d49b0ed0..d6cd061e04 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee import esphome.config_validation as cv @@ -54,7 +54,6 @@ RESTORE_MODES = { } -SwitchCondition = switch_ns.class_("SwitchCondition", Condition) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -201,17 +200,10 @@ for _name, _call in ( _name, SWITCH_ACTION_SCHEMA, automation.ApplyCall(_call) ) - -@automation.register_condition("switch.is_on", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("switch.is_off", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) +automation.register_apply_condition("switch.is_on", SWITCH_ACTION_SCHEMA, "state") +automation.register_apply_condition( + "switch.is_off", SWITCH_ACTION_SCHEMA, "state == false" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ce81f53475..8c7a3a725b 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,16 +6,6 @@ namespace esphome::switch_ { -template class SwitchCondition final : public Condition { - public: - SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - Switch *parent_; - bool state_; -}; - class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb9..ca187b30f1 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -29,3 +29,13 @@ esphome: - switch.control: id: the_switch state: !lambda return (1 > 2); + - if: + condition: + switch.is_on: the_switch + then: + - logger.log: "Switch is on" + - if: + condition: + switch.is_off: the_switch + then: + - logger.log: "Switch is off" From 75360a5d523451d5b179c015f0fc5cec57c18907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:05:21 +0100 Subject: [PATCH 306/316] [core] Collapse ESP8266 flash string ifdefs onto the progmem helpers (#19578) --- .../components/api/api_frame_helper_noise.cpp | 15 ++------------- esphome/components/lock/lock.h | 3 --- esphome/components/mdns/mdns_component.h | 8 ++------ esphome/components/mqtt/mqtt_component.cpp | 6 +----- esphome/components/mqtt/mqtt_lock.cpp | 10 ++-------- esphome/components/mqtt/mqtt_sensor.cpp | 7 ++----- .../prometheus/prometheus_handler.cpp | 14 ++------------ .../components/prometheus/prometheus_handler.h | 8 ++------ esphome/components/script/script.cpp | 8 +------- esphome/components/script/script.h | 17 ++++------------- esphome/components/web_server/web_server.h | 9 +++------ 11 files changed, 21 insertions(+), 84 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 400cd1d9b8..e726a7c967 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -5,16 +5,13 @@ #include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "proto.h" #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::api { using noise::noise_err_to_logstr; @@ -26,11 +23,7 @@ static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, "api and noise component handshake size limits must match"); static const char *const TAG = "api.noise"; -#ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; -#else -static const char *const PROLOGUE_INIT = "NoiseAPIInit"; -#endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) @@ -72,11 +65,7 @@ APIError APINoiseFrameHelper::init() { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } -#ifdef USE_ESP8266 - memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#else - std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#endif + progmem_memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); state_ = State::CLIENT_HELLO; return APIError::OK; diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 86a9cdd3fb..b32d0da8e7 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -32,9 +32,6 @@ enum LockState : uint8_t { }; const LogString *lock_state_to_string(LockState state); -/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 -static constexpr size_t LOCK_STATE_STR_SIZE = 10; - class LockTraits { public: LockTraits() = default; diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index ed06b8e133..93d9eb1e38 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" // On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by // IP state listener events on whichever network interface is configured. #if (defined(USE_ESP8266) || defined(USE_RP2)) && \ @@ -39,12 +40,7 @@ struct MDNSString; // Macro to cast string literals to MDNSString* (works on all platforms) #define MDNS_STR(name) (reinterpret_cast(name)) -#ifdef USE_ESP8266 -#include -#define MDNS_STR_ARG(s) ((PGM_P) (s)) -#else -#define MDNS_STR_ARG(s) (reinterpret_cast(s)) -#endif +#define MDNS_STR_ARG(s) (reinterpret_cast(s)) // Service count is calculated at compile time by Python codegen // MDNS_SERVICE_COUNT will always be defined diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index a80cea6bd6..59a5d02d97 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -312,11 +312,7 @@ bool MQTTComponent::send_discovery_() { // Buffer sized for format string expansion: ~4 bytes net growth from format specifier to 8 hex digits, plus // safety margin char version_buf[sizeof(ver_fmt) + 8]; -#ifdef USE_ESP8266 - snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#else - snprintf(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#endif + ESPHOME_snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); device_info[MQTT_DEVICE_SW_VERSION] = version_buf; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index d555358573..369c1c6668 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -50,14 +50,8 @@ bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; -#ifdef USE_ESP8266 - char buf[LOCK_STATE_STR_SIZE]; - strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; - return this->publish(this->get_state_topic_to_(topic_buf), buf); -#else - return this->publish(this->get_state_topic_to_(topic_buf), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); -#endif + return this->publish(this->get_state_topic_to_(topic_buf), + reinterpret_cast(lock_state_to_string(this->lock_->state))); } } // namespace esphome::mqtt diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 0fa30ef937..a601a9bcc8 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -1,6 +1,7 @@ #include #include "mqtt_sensor.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "mqtt_const.h" @@ -59,11 +60,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_ESP8266 - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); -#else - root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); -#endif + root[MQTT_STATE_CLASS] = reinterpret_cast(state_class_to_string(this->sensor_->get_state_class())); } config.command_topic = false; diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 26b7c6ea80..6e51707ccd 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -144,14 +144,8 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st } } -#ifdef USE_ESP8266 -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, - EntityBase *obj, std::string &area, std::string &node, - std::string &friendly_name) { -#else -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name) { -#endif stream->print(metric_name); stream->print(ESPHOME_F("{id=\"")); stream->print(relabel_id_(obj).c_str()); @@ -903,11 +897,7 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); -#ifdef USE_ESP8266 - stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); -#else - stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); -#endif + stream->print(reinterpret_cast(valve::valve_operation_to_str(obj->current_operation))); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index bc256c6885..5574039e41 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/progmem.h" #ifdef USE_CLIMATE #include "esphome/core/log.h" #endif @@ -68,13 +69,8 @@ class PrometheusHandler final : public AsyncWebHandler, public Component { void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); /// Print metric name and common labels (id, area, node, friendly_name, name) -#ifdef USE_ESP8266 - void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj, - std::string &area, std::string &node, std::string &friendly_name); -#else - void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area, + void print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name); -#endif #ifdef USE_SENSOR /// Return the type for prometheus diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 6a35684da8..bfe96e1b23 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -5,14 +5,8 @@ namespace esphome::script { static const char *const TAG = "script"; -#ifdef USE_ESP8266 -void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { +void ScriptLogger::esp_log_(int level, int line, ProgmemStr format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } -#else -void ScriptLogger::esp_log_(int level, int line, const char *format, const char *param) { - esp_log_printf_(level, TAG, line, format, param); -} -#endif } // namespace esphome::script diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 3f988c9afa..d83555016d 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -8,28 +8,19 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::script { class ScriptLogger { protected: -#ifdef USE_ESP8266 - void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logw_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } - void esp_logd_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logd_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); } - void esp_log_(int level, int line, const __FlashStringHelper *format, const char *param); -#else - void esp_logw_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); - } - void esp_logd_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); - } - void esp_log_(int level, int line, const char *format, const char *param); -#endif + void esp_log_(int level, int line, ProgmemStr format, const char *param); }; /// The abstract base class for all script types. diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 7aa4ac24a3..3e50eeeec0 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -36,12 +37,8 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { -// Type for parameter names that can be stored in flash on ESP8266 -#ifdef USE_ESP8266 -using ParamNameType = const __FlashStringHelper *; -#else -using ParamNameType = const char *; -#endif +// Parameter names live in flash on ESP8266 +using ParamNameType = ProgmemStr; // All platforms need to defer actions to main loop thread. // Multi-core platforms need this for thread safety. From 993c96875cfd7e016c48d190cef23c519bc397ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:05:24 +0100 Subject: [PATCH 307/316] [core][select] Pass the select.set option as a plain literal (#19568) --- esphome/automation.py | 9 +++++++++ esphome/components/climate/__init__.py | 10 ++-------- esphome/components/select/__init__.py | 7 ++++++- tests/unit_tests/test_automation.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 850ac554af..a8c421f065 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -224,6 +224,15 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.safe_exp(value)) +def literal_with_length(config: ConfigType, value: str) -> str: + """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. + + The target compares or copies the bytes in place, so it needs the RAM literal rather than + the PROGMEM rendering on ESP8266, and the length saves a strlen. + """ + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 4c9f58c7a4..e64092d30e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -494,12 +494,6 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -def _literal_with_length(config: ConfigType, value: str) -> str: - # The (const char *, size_t) overload compares bytes in place, so it needs a plain - # literal rather than the default PROGMEM rendering on ESP8266, and skips a strlen. - return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" - - automation.register_apply_action( "climate.control", CLIMATE_CONTROL_ACTION_SCHEMA, @@ -517,14 +511,14 @@ automation.register_apply_action( CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), automation.ApplyField( CONF_CUSTOM_PRESET, "set_preset", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), call="make_call", diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 2db4c8cc34..6b7461bfa8 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -142,7 +142,12 @@ automation.register_apply_action( cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - automation.ApplyField(CONF_OPTION, "set_option", cg.std_string), + automation.ApplyField( + CONF_OPTION, + "set_option", + cg.std_string, + const_fn=automation.literal_with_length, + ), call="make_call", ) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c4265a7888..f6aaff3d92 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -18,6 +18,7 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + literal_with_length, maybe_simple_id, register_apply_action, register_apply_condition, @@ -838,6 +839,23 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_literal_with_length_is_plain_on_every_platform( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + """A (const char *, size_t) target gets the RAM literal and its byte length, never a flash copy.""" + fields = ( + ApplyField("option", "set_option", cg.std_string, const_fn=literal_with_length), + ) + await _run_apply_action( + registries, fields, {"option": "h\u00e9llo"}, platform=platform + ) + text = _apply_lambda(mock_cg) + assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text + assert "progmem_string" not in text + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen From afc5c29569b0793ba8b3c51b53c881612d805399 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:06:29 +0100 Subject: [PATCH 308/316] [event] Trigger events without building a temporary std::string (#19567) --- esphome/components/event/__init__.py | 9 ++++++++- esphome/components/event/event.cpp | 6 +++--- esphome/components/event/event.h | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index f6c3094724..d02863b9ee 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -133,10 +133,17 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) +def _event_type_literal(config: ConfigType, value: str) -> str: + """A constant event type is a plain literal; trigger() only compares it, so no copy is needed.""" + return str(cg.safe_exp(value)) + + automation.register_apply_action( "event.trigger", TRIGGER_EVENT_SCHEMA, - automation.ApplyField(CONF_EVENT_TYPE, "trigger", cg.std_string), + automation.ApplyField( + CONF_EVENT_TYPE, "trigger", cg.std_string, _event_type_literal + ), ) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 673ccc9802..d4ac2644e3 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -7,17 +7,17 @@ namespace esphome::event { static const char *const TAG = "event"; -void Event::trigger(const std::string &event_type) { +void Event::trigger(const char *event_type) { // Linear search with strcmp - faster than std::set for small datasets (1-5 items typical) const char *found = nullptr; for (const char *type : this->types_) { - if (strcmp(type, event_type.c_str()) == 0) { + if (strcmp(type, event_type) == 0) { found = type; break; } } if (found == nullptr) { - ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str()); + ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type); return; } this->last_event_type_ = found; diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index e6fc7111c8..374365c6fb 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -21,7 +21,9 @@ namespace esphome::event { class Event : public EntityBase { public: - void trigger(const std::string &event_type); + /// Trigger an event; the type is matched against the configured types by string compare. + void trigger(const char *event_type); + void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } /// Set the event types supported by this event (from initializer list). void set_event_types(std::initializer_list event_types) { From cbb4c49011dafbf1f746c81dc24c29f96fc3c136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:07:07 +0100 Subject: [PATCH 309/316] [automation] Remove dead trigger classes from sensor, number and text_sensor (#19565) --- esphome/components/number/automation.h | 7 ------ esphome/components/sensor/automation.h | 14 ------------ esphome/components/text_sensor/automation.h | 25 --------------------- 3 files changed, 46 deletions(-) delete mode 100644 esphome/components/text_sensor/automation.h diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index f870acf8dd..b3d9dfaffb 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,13 +6,6 @@ namespace esphome::number { -class NumberStateTrigger final : public Trigger { - public: - explicit NumberStateTrigger(Number *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 0821063434..fdd20461e7 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,20 +6,6 @@ namespace esphome::sensor { -class SensorStateTrigger final : public Trigger { - public: - explicit SensorStateTrigger(Sensor *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - -class SensorRawStateTrigger final : public Trigger { - public: - explicit SensorRawStateTrigger(Sensor *parent) { - parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); - } -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h deleted file mode 100644 index ae632753e5..0000000000 --- a/esphome/components/text_sensor/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::text_sensor { - -class TextSensorStateTrigger final : public Trigger { - public: - explicit TextSensorStateTrigger(TextSensor *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class TextSensorStateRawTrigger final : public Trigger { - public: - explicit TextSensorStateRawTrigger(TextSensor *parent) { - parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text_sensor From acc9324fa8cb853b042402e1ad234820721f6e18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:07:41 +0100 Subject: [PATCH 310/316] [tests] Give the binary_sensor and sensor publish targets distinct ids (#19564) --- tests/components/binary_sensor/common.yaml | 6 +++--- tests/components/sensor/common.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index d9939e7a14..2ff0a47d77 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -38,10 +38,10 @@ binary_sensor: args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor - binary_sensor.template.publish: - id: publish_target + id: binary_sensor_publish_target state: true - binary_sensor.template.publish: - id: publish_target + id: binary_sensor_publish_target state: !lambda "return x.value_or(false);" - if: condition: @@ -56,7 +56,7 @@ binary_sensor: - logger.log: "publish_target is off" - platform: template - id: publish_target + id: binary_sensor_publish_target name: "Publish Target" # Test autorepeat with default configuration (no timings) diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index b8f5183588..08d1ec0c19 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -7,15 +7,15 @@ sensor: update_interval: 1s on_value: - sensor.template.publish: - id: publish_target + id: sensor_publish_target state: 42.0 - sensor.template.publish: - id: publish_target + id: sensor_publish_target state: !lambda "return x * 2.0;" - platform: template name: "Publish Target" - id: publish_target + id: sensor_publish_target # Streaming filters (window_size == send_every) - uses StreamingFilter base class - platform: copy From 2ce2b7b25c93a1cc8f55aeb56579645f8332dd0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:09:35 +0100 Subject: [PATCH 311/316] [event] Remove the unused std::vector set_event_types overload (#19566) --- esphome/components/event/event.cpp | 8 -------- esphome/components/event/event.h | 8 ++------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index d4ac2644e3..00bb9497f9 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -36,12 +36,4 @@ void Event::set_event_types(const FixedVector &event_types) { this->last_event_type_ = nullptr; // Reset when types change } -void Event::set_event_types(const std::vector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } - this->last_event_type_ = nullptr; // Reset when types change -} - } // namespace esphome::event diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 374365c6fb..f57327e33f 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -3,7 +3,6 @@ #include #include #include -#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -25,20 +24,17 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event (from initializer list). + /// Set the event types supported by this event; called by generated code with string literals. void set_event_types(std::initializer_list event_types) { this->types_ = event_types; this->last_event_type_ = nullptr; // Reset when types change } - /// Set the event types supported by this event (from FixedVector). + /// Copy the event types of another event, for components that wrap one. void set_event_types(const FixedVector &event_types); - /// Set the event types supported by this event (from vector). - void set_event_types(const std::vector &event_types); // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; - void set_event_types(const std::vector &event_types) = delete; /// Return the event types supported by this event. const FixedVector &get_event_types() const { return this->types_; } From cbd985826c61260f696ca0cfc5e44bb305d0dcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:10:09 +0100 Subject: [PATCH 312/316] [speaker] Use register_apply_condition for speaker.is_playing and speaker.is_stopped (#19562) --- esphome/components/speaker/__init__.py | 23 ++++++----------------- esphome/components/speaker/automation.h | 10 ---------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index e62e4ccfd3..d0a743543e 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -20,10 +20,6 @@ PlayAction = speaker_ns.class_( ) -IsPlayingCondition = speaker_ns.class_("IsPlayingCondition", automation.Condition) -IsStoppedCondition = speaker_ns.class_("IsStoppedCondition", automation.Condition) - - async def setup_speaker_core_(var, config): if audio_dac_config := config.get(CONF_AUDIO_DAC): aud_dac = await cg.get_variable(audio_dac_config) @@ -47,12 +43,6 @@ SPEAKER_AUTOMATION_SCHEMA = automation.maybe_simple_id( ) -async def speaker_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - @automation.register_action( "speaker.play", PlayAction, @@ -91,13 +81,12 @@ for _name, _call in ( _name, SPEAKER_AUTOMATION_SCHEMA, automation.ApplyCall(_call) ) -automation.register_condition( - "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) - -automation.register_condition( - "speaker.is_stopped", IsStoppedCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) +automation.register_apply_condition( + "speaker.is_playing", SPEAKER_AUTOMATION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "speaker.is_stopped", SPEAKER_AUTOMATION_SCHEMA, "is_stopped()" +) automation.register_apply_action( diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 50097ab59a..a44b79f05e 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -38,14 +38,4 @@ template class PlayAction final : public Action, public P } data_; }; -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsStoppedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_stopped(); } -}; - } // namespace esphome::speaker From fed5dd6a9d3364cf57e28c88154e8532f0175a6f Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:40 -0400 Subject: [PATCH 313/316] =?UTF-8?q?[light]=20Accept=20the=20top-level=20wh?= =?UTF-8?q?ite=20key=20from=20Home=20Assistant's=20MQTT=20JSO=E2=80=A6=20(?= =?UTF-8?q?#19570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/light/light_json_schema.cpp | 7 ++ tests/components/light/__init__.py | 11 +++ .../light/test_light_json_schema.cpp | 74 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/components/light/__init__.py create mode 100644 tests/components/light/test_light_json_schema.cpp diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index aaa1176f9f..8624fade16 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -131,6 +131,13 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } + if (root[ESPHOME_F("white")].is()) { + // White stays full because ESPHome multiplies brightness and white + call.set_color_mode_if_supported(ColorMode::WHITE); + call.set_brightness(float(root[ESPHOME_F("white")]) / 255.0f); + call.set_white(1.0f); + } + if (root[ESPHOME_F("color_temp")].is()) { call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py new file mode 100644 index 0000000000..219b61dbf5 --- /dev/null +++ b/tests/components/light/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Enables light_json_schema.cpp without USE_MQTT, which pulls mqtt code into core/util.cpp + async def to_code_testing(config): + cg.add_define("USE_WEBSERVER") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/test_light_json_schema.cpp b/tests/components/light/test_light_json_schema.cpp new file mode 100644 index 0000000000..d7eb9ca56f --- /dev/null +++ b/tests/components/light/test_light_json_schema.cpp @@ -0,0 +1,74 @@ +#include + +#include "esphome/components/json/json_util.h" +#include "esphome/components/light/light_call.h" +#include "esphome/components/light/light_json_schema.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// An `rgbw` light with `color_interlock: true` +class InterlockedRgbwOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::RGB, ColorMode::WHITE}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class TestableLightCall : public LightCall { + public: + using LightCall::LightCall; + using LightCall::validate_; +}; + +LightColorValues parse(LightState &state, const char *payload) { + TestableLightCall call(&state); + json::parse_json(payload, [&](JsonObject root) { + LightJSONSchema::parse_json(state, call, root); + return true; + }); + return call.validate_(); +} + +} // namespace + +// HA's MQTT JSON schema selects the white color mode with a top-level `white` key +TEST(LightJSONSchema, TopLevelWhiteSelectsWhiteMode) { + InterlockedRgbwOutput output; + LightState state(&output); + state.remote_values.set_color_mode(ColorMode::RGB); + + auto values = parse(state, R"({"state":"ON","white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +TEST(LightJSONSchema, TopLevelWhiteOverridesBrightness) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","brightness":255,"white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); +} + +TEST(LightJSONSchema, ColorWStillSetsWhite) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","color":{"w":255}})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +} // namespace esphome::light::testing From e92b1e27ca15768ca79bb0c57082234856e83721 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:12:04 +0100 Subject: [PATCH 314/316] [core] Let register_apply_action read the parent id from another schema key (#19548) --- esphome/automation.py | 20 ++++++++++------- tests/unit_tests/test_automation.py | 35 ++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index a8c421f065..4a67cd31ba 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -339,9 +339,9 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] -async def _apply_parent(config: ConfigType) -> str: +async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: # Global-scope qualified so a trigger arg named like the id cannot shadow it. - return f"::{await cg.get_variable(config[CONF_ID])}" + return f"::{await cg.get_variable(config[id_key])}" def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: @@ -396,12 +396,14 @@ def register_apply_action( schema: cv.Schema, *fields: ApplyField | ApplyCall, call: str | None = None, + id_key: str = CONF_ID, ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. - Generates one stateless function for ``ApplyAction``: parent and constants are baked - in, lambdas are called inline with the trigger args. With ``call`` every statement targets - the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. + Generates one stateless function for ``ApplyAction``: the parent (read from + ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, + and ``apply_call.perform()`` is appended. """ # An action stores the value, so a std::string constant stays in flash on ESP8266. statements_spec = [ @@ -414,6 +416,7 @@ def register_apply_action( ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] + _check_key_in_schema(name, schema, id_key) for _, members in statements_spec: for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) @@ -424,7 +427,7 @@ def register_apply_action( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - parent = await _apply_parent(config) + parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] @@ -451,7 +454,7 @@ def register_apply_action( def register_apply_condition( - name: str, schema: cv.Schema, check: str | ApplyCall + name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID ) -> None: """Register a condition that is one expression on its parent, with no C++ class. @@ -463,6 +466,7 @@ def register_apply_condition( """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) members = call.members + _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) @@ -472,7 +476,7 @@ def register_apply_condition( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - parent = await _apply_parent(config) + parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) exprs = await _render_values( name, diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f6aaff3d92..1e1d2e714a 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -610,12 +610,13 @@ async def _run_entry( config: dict[str, object], args: list[tuple[object, str]] | None, platform: str, + id_key: str = CONF_ID, ) -> RegistryEntry: """Run a registered builder with the given config, trigger args and platform.""" CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} args = args or [] template_arg = cg.TemplateArguments(*(t for t, _ in args)) - await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + await entry.fun({id_key: PARENT_ID, **config}, ID("obj_1"), template_arg, args) return entry @@ -626,11 +627,12 @@ async def _run_apply_action( args: list[tuple[object, str]] | None = None, call: str | None = None, platform: str = "esp32", + id_key: str = CONF_ID, ) -> RegistryEntry: """Register an apply action and run its builder with the given config.""" actions, _ = registries - register_apply_action("my.apply", None, *fields, call=call) - return await _run_entry(actions["my.apply"], config, args, platform) + register_apply_action("my.apply", None, *fields, call=call, id_key=id_key) + return await _run_entry(actions["my.apply"], config, args, platform, id_key) async def _run_apply_condition( @@ -639,11 +641,12 @@ async def _run_apply_condition( config: dict[str, object], args: list[tuple[object, str]] | None = None, platform: str = "esp32", + id_key: str = CONF_ID, ) -> RegistryEntry: """Register an apply condition and run its builder with the given config.""" _, conditions = registries - register_apply_condition("my.check", None, check) - return await _run_entry(conditions["my.check"], config, args, platform) + register_apply_condition("my.check", None, check, id_key=id_key) + return await _run_entry(conditions["my.check"], config, args, platform, id_key) def _apply_lambda(mock_cg: MockCodegen) -> str: @@ -663,6 +666,17 @@ async def test_register_apply_action_entry( assert str(template_arg) == "" +@pytest.mark.asyncio +async def test_apply_custom_id_key( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + await _run_apply_action(registries, (), {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.get_variable.reset_mock() + await _run_apply_condition(registries, "is_on()", {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + + @pytest.mark.asyncio async def test_apply_constants( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -806,6 +820,10 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_condition( "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) ) + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_action("my.bad_id", schema, id_key="parent_id") + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_condition("my.bad_is_id", schema, "is_on()", id_key="parent_id") either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) for wrapped in ( @@ -818,7 +836,12 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_action( "my.bad", wrapped, ApplyField("kd", "set_kd", cg.float_) ) - nested = cv.Schema({cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_})}) + nested = cv.Schema( + { + cv.Required(CONF_ID): cv.string, + cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_}), + } + ) register_apply_action( "my.nested", nested, ApplyField(("v", "dir"), "set_dir", cg.int_) ) From 22177680c450984388405b8fb8883247c0030cd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:15:18 +0100 Subject: [PATCH 315/316] [tests] Point the binary_sensor conditions at the renamed publish target (#19583) --- tests/components/binary_sensor/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 2ff0a47d77..a0368c44b9 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -45,13 +45,13 @@ binary_sensor: state: !lambda "return x.value_or(false);" - if: condition: - binary_sensor.is_on: publish_target + binary_sensor.is_on: binary_sensor_publish_target then: - logger.log: "publish_target is on" - if: condition: binary_sensor.is_off: - id: publish_target + id: binary_sensor_publish_target then: - logger.log: "publish_target is off" From b0c9a7d5bdfef10c4fc1fb3225f9896503cb3db6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:21:32 +0100 Subject: [PATCH 316/316] [core] Lint duplicate UNIT_/CONF_ constants against both const homes (#19422) --- esphome/components/as7341/sensor.py | 3 +- esphome/components/const/__init__.py | 1 + .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/growatt_solar/sensor.py | 2 +- esphome/components/havells_solar/sensor.py | 2 +- esphome/components/ina2xx_base/__init__.py | 2 +- esphome/components/ltr390/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 2 +- esphome/components/ltr_als_ps/sensor.py | 2 +- esphome/components/lvgl/defines.py | 2 - esphome/components/ota/__init__.py | 2 +- esphome/components/selec_meter/sensor.py | 6 +- esphome/components/veml7700/sensor.py | 2 +- script/ci-custom.py | 74 ++++++++++++++++-- tests/script/test_ci_custom.py | 76 +++++++++++++++++++ 15 files changed, 157 insertions(+), 24 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index f70c5e999f..3afab8422f 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, @@ -33,8 +34,6 @@ CONF_F7 = "f7" CONF_F8 = "f8" CONF_NIR = "nir" -UNIT_COUNTS = "#" - AS7341_GAIN = as7341_ns.enum("AS7341Gain") GAIN_OPTIONS = { "X0.5": AS7341_GAIN.AS7341_GAIN_0_5X, diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 256ab5c0a3..31b0a0c097 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -61,3 +61,4 @@ ICON_SOLAR_POWER = "mdi:solar-power" KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" +UNIT_COUNTS = "#" diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 924d11db2b..9355d58859 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -37,7 +37,6 @@ CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" CONF_CHARACTERISTICS = "characteristics" -CONF_DESCRIPTION = "description" CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 2e2b218730..3728710b1f 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, ) @@ -33,7 +34,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index dcea1afd04..efb7af2c3c 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_DEGREES, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_MINUTE, UNIT_VOLT, UNIT_VOLT_AMPS_REACTIVE, @@ -36,7 +37,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7bb589f0b1..474283ccce 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, + UNIT_MILLIVOLT, UNIT_VOLT, UNIT_WATT, UNIT_WATT_HOURS, @@ -44,7 +45,6 @@ CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" CONF_RESET_ON_BOOT = "reset_on_boot" UNIT_COULOMB = "C" UNIT_JOULE = "J" -UNIT_MILLIVOLT = "mV" ina2xx_base_ns = cg.esphome_ns.namespace("ina2xx_base") INA2XX = ina2xx_base_ns.class_("INA2XX", cg.PollingComponent) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index c3ac90ad11..6c25729bee 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, @@ -28,7 +29,6 @@ CONF_UV_INDEX = "uv_index" CONF_UV = "uv" CONF_WINDOW_CORRECTION_FACTOR = "window_correction_factor" -UNIT_COUNTS = "#" UNIT_UVI = "UVI" LTR390GAIN = ltr390_ns.enum("LTR390GAIN") diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c2091a6336..e27a7c323e 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -42,7 +43,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr501_ns = cg.esphome_ns.namespace("ltr501") diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index af09282e2d..7bd476156b 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -41,7 +42,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr_als_ps_ns = cg.esphome_ns.namespace("ltr_als_ps") diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 73fc58736b..f601b4b57a 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -680,7 +680,6 @@ CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" @@ -767,7 +766,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index a2e6953a16..6031a68489 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_STATE_CHANGE from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -38,7 +39,6 @@ CONF_ON_ABORT = "on_abort" CONF_ON_BEGIN = "on_begin" CONF_ON_END = "on_end" CONF_ON_PROGRESS = "on_progress" -CONF_ON_STATE_CHANGE = "on_state_change" ota_ns = cg.esphome_ns.namespace("ota") diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 0d4e0fafc5..04aef00d46 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -27,6 +27,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE, @@ -44,9 +47,6 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" -UNIT_KILOWATT_HOURS = "kWh" -UNIT_KILOVOLT_AMPS_HOURS = "kVAh" -UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 4afca4b868..02820505ed 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -31,7 +32,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] -UNIT_COUNTS = "#" ICON_MULTIPLICATION = "mdi:multiplication" ICON_BRIGHTNESS_7 = "mdi:brightness-7" diff --git a/script/ci-custom.py b/script/ci-custom.py index 6672760b06..bdf7750ce8 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -529,21 +529,67 @@ def lint_conf_matches(fname, match): CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle: constants_content = const_f_handle.read() +with codecs.open( + "esphome/components/const/__init__.py", "r", encoding="utf-8" +) as component_const_f_handle: + component_constants_content = component_const_f_handle.read() + +# The two canonical homes for shared constants: esphome/const.py (core, frozen) and +# esphome/components/const/__init__.py (shared by components). A constant defined in +# either must be imported from there rather than redefined in a component. +CONST_HOMES = ["esphome/const.py", "esphome/components/const/__init__.py"] + CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)] +COMPONENT_CONSTANTS = [ + m.group(1) for m in re.finditer(CONF_RE, component_constants_content, re.MULTILINE) +] CONSTANTS_USES = collections.defaultdict(list) -@lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"]) +def _const_home_error(name, core_constants, component_constants): + """Return an error if the constant already lives in one of the canonical homes.""" + if name in core_constants: + return ( + f"Constant {highlight(name)} has already been defined in const.py - " + "please import the constant from const.py directly." + ) + if name in component_constants: + return ( + f"Constant {highlight(name)} has already been defined in " + "esphome/components/const/__init__.py - please import the constant from " + "esphome.components.const directly." + ) + return None + + +@lint_re_check(CONF_RE, include=["*.py"], exclude=CONST_HOMES) def lint_conf_from_const_py(fname, match): name = match.group(1) - if name not in CONSTANTS: + err = _const_home_error(name, CONSTANTS, COMPONENT_CONSTANTS) + if err is None: CONSTANTS_USES[name].append(fname) - return None - return ( - f"Constant {highlight(name)} has already been defined in const.py - " - "please import the constant from const.py directly." - ) + return err + + +UNIT_RE = r'^(UNIT_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' +UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, constants_content, re.MULTILINE) +] +COMPONENT_UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, component_constants_content, re.MULTILINE) +] + +UNIT_CONSTANTS_USES = collections.defaultdict(list) + + +@lint_re_check(UNIT_RE, include=["*.py"], exclude=CONST_HOMES) +def lint_unit_from_const_py(fname, match): + name = match.group(1) + err = _const_home_error(name, UNIT_CONSTANTS, COMPONENT_UNIT_CONSTANTS) + if err is None: + UNIT_CONSTANTS_USES[name].append(fname) + return err RAW_PIN_ACCESS_RE = ( @@ -711,6 +757,20 @@ def lint_constants_usage(): return errs +@lint_post_check +def lint_unit_constants_usage(): + errs = [] + for constant, uses in UNIT_CONSTANTS_USES.items(): + if len(uses) < 3: + continue + errs.append( + f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " + f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" + ) + return errs + + # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 5b4d4fe5c8..be2c98051f 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -151,6 +151,82 @@ def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") +# --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- + +# Real UNIT_ constants that live in each canonical home. +UNIT_IN_CONST_PY = ci_custom.UNIT_CONSTANTS[0] +UNIT_IN_COMPONENT_CONST = ci_custom.COMPONENT_UNIT_CONSTANTS[0] + + +def _unit_def(fname: str, content: str) -> list: + return ci_custom.lint_unit_from_const_py(fname, content) + + +def test_unit_already_in_const_py_is_flagged() -> None: + errs = _unit_def("esphome/components/x/sensor.py", f'{UNIT_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_unit_already_in_component_const_is_flagged() -> None: + errs = _unit_def( + "esphome/components/x/sensor.py", f'{UNIT_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_unit_not_in_const_py_is_tracked_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + assert _unit_def("a.py", 'UNIT_FOO_BAR = "fb"\n') == [] + assert ci_custom.UNIT_CONSTANTS_USES["UNIT_FOO_BAR"] == ["a.py"] + + +def test_unit_defined_in_three_files_is_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py", "c.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + errs = ci_custom.lint_unit_constants_usage() + assert any("UNIT_FOO_BAR" in e and "3 files" in e for e in errs) + + +def test_unit_defined_in_two_files_is_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + assert ci_custom.lint_unit_constants_usage() == [] + + +# --- same rule for CONF_, now also recognising the components/const home --- + +CONF_IN_CONST_PY = ci_custom.CONSTANTS[0] +CONF_IN_COMPONENT_CONST = ci_custom.COMPONENT_CONSTANTS[0] + + +def _conf_def(fname: str, content: str) -> list: + return ci_custom.lint_conf_from_const_py(fname, content) + + +def test_conf_already_in_const_py_is_flagged() -> None: + errs = _conf_def("esphome/components/x/sensor.py", f'{CONF_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_conf_already_in_component_const_is_flagged() -> None: + errs = _conf_def( + "esphome/components/x/sensor.py", f'{CONF_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_conf_not_in_a_const_home_is_tracked_not_flagged() -> None: + ci_custom.CONSTANTS_USES.pop("CONF_FOO_BAR", None) + assert _conf_def("a.py", 'CONF_FOO_BAR = "foo_bar"\n') == [] + assert ci_custom.CONSTANTS_USES["CONF_FOO_BAR"] == ["a.py"] + + # --- ESP_LOG call scanner and bare-literal-ternary lint ---