From 78b60ac6fa8b173163d74651a719cc1ed3d2e919 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 May 2026 12:33:43 +1200 Subject: [PATCH 001/282] Bump version to 2026.6.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..3537516996 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.5.0-dev +PROJECT_NUMBER = 2026.6.0-dev # 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 a256a10e62..fa1ea42bc6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0-dev" +__version__ = "2026.6.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d2107e40c8c0e019de1984009ba9a4c851d88736 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2026 21:03:45 -0500 Subject: [PATCH 002/282] [ci] Prohibit curly braces in PR titles for MDX safety (#16412) --- .github/workflows/pr-title-check.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index ed0bff9664..e15d09da82 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -68,14 +68,15 @@ jobs: return; } - // Check for angle brackets not wrapped in backticks. - // Astro docs MDX treats bare < as JSX component opening tags. + // Check for MDX syntax characters not wrapped in backticks. + // Astro docs MDX treats bare `<` as JSX component opening tags and + // bare `{` as JS expressions, so both must be escaped in changelog entries. const stripped = title.replace(/`[^`]*`/g, ''); - if (/[<>]/.test(stripped)) { + if (/[<>{}]/.test(stripped)) { core.setFailed( - 'PR title contains `<` or `>` not wrapped in backticks.\n' + - 'Astro docs MDX interprets bare `<` as JSX components.\n' + - 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' + + 'Please wrap these characters with backticks, e.g.: [component] Add `` support' ); return; } From e593cb6efc2f9a8243718cedbc3f048bda5356db Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 13 May 2026 23:19:30 -0400 Subject: [PATCH 003/282] [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/espidf/toolchain.py | 30 ++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1eb0bb2174..0c24dbf7b9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2509,7 +2509,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 583f340996..1245c643e1 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,13 +191,19 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if configuration inputs - are newer than the generated CMake/Ninja build artifacts. + Returns True if required build files are missing or if external + configuration inputs (IDF install, sdkconfig, CMake's own build/config + dir) are newer than CMakeCache.txt. We deliberately don't watch the + top-level/src ``CMakeLists.txt`` here -- those are written by + ``write_project`` via ``write_file_if_changed`` (so an mtime bump + means our content actually changed) and ninja already tracks them as + configure-time deps via ``build.ninja``. Including them in this check + causes a perpetual reconfigure loop: the two-pass write leaves + CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the + cache when only ``idf_build_set_property`` values change, so the + check would trip on every subsequent build. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") - - cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") - cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" @@ -221,8 +227,6 @@ def has_outdated_files(): os.path.getmtime(f) > cmakecache_txt_mtime for f in [ _get_idf_path(), - cmakelists_txt_build_path, - cmakelists_txt_src_path, sdkconfig_internal_path, build_config_path, ] @@ -302,21 +306,13 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) - # The post-discovery rewrite leaves CMakeLists newer than - # CMakeCache.txt. CMake won't re-touch CMakeCache.txt on a - # configure that only changes idf_build_set_property values - # (those aren't cache variables), so has_outdated_files() would - # return True on every subsequent build, perpetually retriggering - # the two-pass. Touch CMakeCache.txt now so its mtime stays past - # the rewritten CMakeLists. - cmakecache = CORE.relative_build_path("build/CMakeCache.txt") - if cmakecache.is_file(): - os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- # idf.py build would otherwise re-run cmake and regenerate # memory.ld, wiping the DRAM/IRAM patches applied below. + # Outside testing mode ninja's own configure-time dep on + # CMakeLists.txt handles the re-run as part of the build step. rc = run_reconfigure() if rc != 0: _LOGGER.error("Reconfigure with discovered components failed") From f89a6f4f9c804d118efb7da701962920186dc44b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 00:02:22 -0400 Subject: [PATCH 004/282] [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) --- esphome/components/esp32/__init__.py | 8 ++++- esphome/espidf/toolchain.py | 45 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c24dbf7b9..f112549832 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2464,8 +2464,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 1245c643e1..e0bc5bb393 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -191,23 +191,38 @@ def run_reconfigure() -> int: def has_outdated_files(): """Check if the build configuration is stale. - Returns True if required build files are missing or if external - configuration inputs (IDF install, sdkconfig, CMake's own build/config - dir) are newer than CMakeCache.txt. We deliberately don't watch the - top-level/src ``CMakeLists.txt`` here -- those are written by - ``write_project`` via ``write_file_if_changed`` (so an mtime bump - means our content actually changed) and ninja already tracks them as - configure-time deps via ``build.ninja``. Including them in this check - causes a perpetual reconfigure loop: the two-pass write leaves - CMakeLists newer than CMakeCache.txt, and CMake doesn't restamp the - cache when only ``idf_build_set_property`` values change, so the - check would trip on every subsequent build. + Returns True if required build files are missing or if ESPHome's + resolved build inputs are newer than CMakeCache.txt: + + - ``sdkconfig..esphomeinternal`` -- the canonical "what state + did ESPHome resolve the YAML to" snapshot. Any change in build + flags, enabled components, framework version, or target ends up + rewriting it (we embed a ``# ESPHOME_IDF_VERSION=`` comment line + for the version case where the option set would otherwise be + identical). + - ``src/idf_component.yml`` -- the project manifest. Managed + component additions/removals (e.g. via ``add_idf_component``) can + happen without any sdkconfig impact, and ``_write_idf_component_yml`` + already deletes ``dependencies.lock`` on a change but that signal + gets lost as soon as the lock is missing. + + We deliberately don't watch: + - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and + ninja already tracks them as configure-time deps. Including them + causes a perpetual reconfigure loop because CMake doesn't restamp + ``CMakeCache.txt`` when only ``idf_build_set_property`` values + change between configures. + - ``$IDF_PATH`` and CMake's ``build/config/`` -- both have mtime + semantics that fire after the wrong configure (or not at all in + common cases like in-place IDF version replacement). The sdkconfig + and manifest hashes subsume the meaningful signal. """ cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") build_config_path = CORE.relative_build_path("build/config") sdkconfig_internal_path = CORE.relative_build_path( f"sdkconfig.{CORE.name}.esphomeinternal" ) + idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -225,12 +240,8 @@ def has_outdated_files(): cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) return any( os.path.getmtime(f) > cmakecache_txt_mtime - for f in [ - _get_idf_path(), - sdkconfig_internal_path, - build_config_path, - ] - if f and os.path.exists(f) + for f in [sdkconfig_internal_path, idf_component_yml_path] + if f.exists() ) From 348b92910ed508a641467ec246bac01ae4f731da Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:07:38 -0500 Subject: [PATCH 005/282] [tinyusb] Reject `tinyusb:` configured without a USB class companion (#16413) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 20 +++++++++++++++++++ .../components/tinyusb/tinyusb_component.cpp | 15 ++++++++++++++ tests/components/tinyusb/common.yaml | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index df94ad7534..724f65721b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,3 +1,4 @@ +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +# 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 +# esp_tinyusb's driver install (descriptors_set fails with no class and no +# user-provided full_speed_config), which trips the task watchdog before +# loop() ever runs. +_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) + tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) @@ -41,6 +49,18 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + full_config = fv.full_config.get() + if not any(name in full_config for name in _USB_CLASS_COMPONENTS): + raise cv.Invalid( + "The 'tinyusb' component requires at least one USB class component" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 2ec696c3e4..3cefc0454a 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,21 @@ void TinyUSB::setup() { .string_count = SIZE, }; + // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with + // ESP_ERR_INVALID_ARG when no configuration descriptor is provided and + // no class that has a built-in default (CDC/MSC/NCM) is compiled in. In + // that case the internal task exits without notifying us, and + // tinyusb_driver_install() blocks 5s on the notify-take -- long enough + // to trip the task watchdog. Bail early so the rest of the device can + // still boot. +#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0) + if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) { + ESP_LOGE(TAG, "No USB class configured"); + this->mark_failed(); + return; + } +#endif + 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)); diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index cb3f48836a..674e89dbe8 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,3 +6,8 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + +# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. +usb_cdc_acm: + interfaces: + - id: tinyusb_test_cdc From 7436d1c1999cffa4e2121fd223a992808469dab9 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 14 May 2026 15:29:56 -0500 Subject: [PATCH 006/282] [tinyusb] Reject `logger.hardware_uart: USB_CDC` (#16417) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/tinyusb/__init__.py | 13 ++++++++++++- tests/components/tinyusb/test.esp32-s2-idf.yaml | 5 +++++ tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 724f65721b..0e02ff8724 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_HARDWARE_UART, CONF_ID CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] @@ -55,6 +55,17 @@ def _final_validate(config): raise cv.Invalid( "The 'tinyusb' component requires at least one USB class component" ) + # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes + # the ROM console through that same peripheral, so the two cannot coexist. + # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) + logger_config = full_config.get("logger") + if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": + raise cv.Invalid( + "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " + "because both share the USB OTG peripheral. Set " + "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " + "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + ) return config diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index dade44d145..09b98ada40 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1 +1,6 @@ <<: !include common.yaml + +# 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. +logger: + hardware_uart: UART0 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml index f159b38ff6..ff75731509 100644 --- a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -1,5 +1,10 @@ <<: !include tinyusb_common.yaml +# 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. +logger: + hardware_uart: UART0 + usb_cdc_acm: interfaces: - id: usb_cdc_acm1 From a8e69a15e40b9fe4809ee4c3555053baf23503d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:38:09 -0400 Subject: [PATCH 007/282] [clang-tidy] Enable readability-container-contains (#16438) --- .clang-tidy | 1 - .clang-tidy.hash | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/spi/spi.cpp | 4 ++-- esphome/components/touchscreen/touchscreen.cpp | 2 +- esphome/components/uponor_smatrix/uponor_smatrix.cpp | 2 +- esphome/components/web_server/web_server.cpp | 4 ++-- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index ea7370a3b2..6dab84fbd9 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -116,7 +116,6 @@ Checks: >- -portability-template-virtual-member-function, -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, - -readability-container-contains, -readability-container-data-pointer, -readability-convert-member-functions-to-static, -readability-else-after-return, diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 77b4f5323f..52d75d1601 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c +27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 678ed9dbbf..12bf6d9f37 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -572,7 +572,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) { auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; - if (self->key_map_.count(key_idx) != 0) { + if (self->key_map_.contains(key_idx)) { self->send_key_(self->key_map_[key_idx]); return; } diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 20359135ba..dfdc9fa624 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -16,7 +16,7 @@ GPIOPin *const NullPin::NULL_PIN = new NullPin(); // NOLINT(cppcoreguidelines-a SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only) { - if (this->devices_.count(device) != 0) { + if (this->devices_.contains(device)) { ESP_LOGE(TAG, "Device already registered"); return this->devices_[device]; } @@ -27,7 +27,7 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB } void SPIComponent::unregister_device(SPIClient *device) { - if (this->devices_.count(device) == 0) { + if (!this->devices_.contains(device)) { esph_log_e(TAG, "Device not registered"); return; } diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index 5687213eb5..f4ef66ef3e 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -78,7 +78,7 @@ void Touchscreen::add_raw_touch_position_(uint8_t id, int16_t x_raw, int16_t y_r if (this->swap_x_y_) { std::swap(x_raw, y_raw); } - if (this->touches_.count(id) == 0) { + if (!this->touches_.contains(id)) { tp.state = STATE_PRESSED; tp.id = id; } else { diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 3f1feaa927..0ba19f5cd7 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -154,7 +154,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { } // Log unknown device addresses - if (!found && !this->unknown_devices_.count(device_address)) { + if (!found && !this->unknown_devices_.contains(device_address)) { ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address); this->unknown_devices_.insert(device_address); } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 198267204d..150f70aa6b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2638,9 +2638,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING - if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { + if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; - if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { + if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } From f291dc8d2feeb1a22882e3bba103793d9916e111 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:39:16 -0400 Subject: [PATCH 008/282] [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) --- esphome/components/esp32/__init__.py | 4 +++- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/heatpumpir/climate.py | 1 - esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++-- esphome/components/sim800l/sim800l.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 ++ esphome/components/tx20/tx20.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 4 ++-- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f112549832..e9b0f1fd0a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1767,9 +1767,11 @@ async def to_code(config): else: cg.add_build_flag("-Wno-error=format") cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") + cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..aa3a08c294 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -125,7 +125,6 @@ async def to_code(config): cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index 3fe1c722eb..d6959d1a96 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) { edge_state.last_sent_edge_us_ = now; state.last_detected_edge_us_ = now; state.last_rising_edge_us_ = now; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // This ISR is bound to rising edges, so the pin is high @@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) { } else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge pulse_state.latched_ = true; state.last_detected_edge_us_ = pulse_state.last_intr_; - state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile) + state.count_ += 1; } // Due to order of operations this includes diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index b8e97b1121..13b9888e05 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; } - // Else fall thru ... + [[fallthrough]]; } case STATE_CHECK_SMS: send_cmd_("AT+CMGL=\"ALL\""); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b29905f9a0..fd14844908 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType case 4: data.push_back(value >> 24); data.push_back(value >> 16); + [[fallthrough]]; case 2: data.push_back(value >> 8); + [[fallthrough]]; case 1: data.push_back(value >> 0); break; diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 353cb31513..3574bd7c2d 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() { } if (tx20_se == tx20_sb) { tx20_wind_direction = tx20_se; - if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) { + if (tx20_wind_direction < 16) { wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction]; } ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction); @@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->buffer[arg->buffer_index] = 1; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; return; } const uint32_t delay = now - arg->start_time; @@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { } arg->spent_time += delay; arg->start_time = now; - arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->buffer_index += 1; } void IRAM_ATTR Tx20ComponentStore::reset() { tx20_available = false; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index e88c41c0cb..fb8425f6cd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedarg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e1d3e4bf34..c32acaf03e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -66,7 +66,7 @@ namespace { * - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK). * - HTTPD_SOCK_ERR_FAIL for other errors. */ -int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { +[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) { if (buf == nullptr) { return HTTPD_SOCK_ERR_INVALID; } diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index e5c29f8b11..df64cd48aa 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { if (arg->d0.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value <<= 1; arg->last_bit_time = millis(); arg->done = false; @@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) { if (arg->d1.digital_read()) return; - arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile) + arg->count += 1; arg->value = (arg->value << 1) | 1; arg->last_bit_time = millis(); arg->done = false; From f3d77434604b29122d71cad46877d9b991cf1614 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:40:40 -0400 Subject: [PATCH 009/282] [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) --- esphome/components/logger/__init__.py | 14 +++++----- esphome/components/lvgl/helpers.py | 28 +++++++++---------- .../components/esp32_ble_tracker/common.yaml | 4 +-- tests/components/ld2412/common.yaml | 2 +- tests/components/lvgl/lvgl-package.yaml | 6 ++-- tests/components/modbus_server/common.yaml | 2 +- tests/components/mqtt/common.yaml | 2 +- tests/components/nextion/common.yaml | 2 +- .../remote_receiver/common-actions.yaml | 2 +- tests/components/script/common.yaml | 2 +- tests/components/udp/common.yaml | 2 +- tests/components/udp/test.host.yaml | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9d7dc8d92c..c6c440564a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None: def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..6f70a1e3bd 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan" # noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, @@ -23,13 +23,13 @@ f_regex = re.compile( # noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 018bbb42b3..564cf1f6ea 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -29,12 +29,12 @@ esp32_ble_tracker: - service_uuid: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of service data is %i", x.size()); + ESP_LOGD("main", "Length of service data is %zu", x.size()); on_ble_manufacturer_data_advertise: - manufacturer_id: ABCD then: - lambda: !lambda |- - ESP_LOGD("main", "Length of manufacturer data is %i", x.size()); + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); on_scan_end: - then: - lambda: |- diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml index c5bda688dc..7a86b6fbda 100644 --- a/tests/components/ld2412/common.yaml +++ b/tests/components/ld2412/common.yaml @@ -123,7 +123,7 @@ select: - lambda: |- id(uart_bus).flush(); uint32_t new_baud_rate = stoi(x); - ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate); + ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate); if (id(uart_bus).get_baud_rate() != new_baud_rate) { id(uart_bus).set_baud_rate(new_baud_rate); #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 53984bb006..0f4b961297 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -660,13 +660,13 @@ lvgl: on_release: logger.log: format: Button released at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_long_press_repeat: logger.log: Button clicked on_pressing: logger.log: format: Button pressing at %d/%d - args: [point.x, point.y] + args: ['(int) point.x', '(int) point.y'] on_press_lost: logger.log: Button press lost on_single_click: @@ -944,7 +944,7 @@ lvgl: on_release: logger.log: format: Slider released at %d/%d with value %.0f - args: [point.x, point.y, x] + args: ['(int) point.x', '(int) point.y', x] - button: styles: spin_button id: spin_up diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 3522c9248c..2e4a81a1aa 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -21,7 +21,7 @@ modbus_server: read_lambda: |- return 31; write_lambda: |- - printf("address=%d, value=%d", x); + printf("address=%d, value=%" PRId32 "\n", (int) address, x); return true; - id: modbus_server4 modbus_id: mod_bus2 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 8c58e9b080..6af2ce3939 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -64,7 +64,7 @@ mqtt: topic: some/topic payload: Good-bye - lambda: |- - ESP_LOGD("MQTT", "Disconnect reason %d", reason); + ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason); publish_nan_as_none: false binary_sensor: diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 0616b9a41a..fba6a22b97 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -299,7 +299,7 @@ display: - lambda: |- // key: StringRef, value: int32_t if (key == "temperature_raw") { - ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value); } on_custom_binary_sensor: then: diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 30b99eeb70..26a02d4dab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -12,7 +12,7 @@ on_brennenstuhl: then: - logger.log: format: "on_brennenstuhl: %u" - args: ["x.code"] + args: ["(unsigned) x.code"] on_aeha: then: - logger.log: diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index c1dc68513f..f4818e2296 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -49,7 +49,7 @@ script: then: - lambda: |- ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s", - ints[0], floats[0], bools[0], strings[0].c_str()); + ints[0], floats[0], (int) bools[0], strings[0].c_str()); - id: my_script_with_params parameters: prefix: string diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 3466e8d2ee..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -11,7 +11,7 @@ udp: - "10.0.0.255" on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 84e78894e5..825d86c19e 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -4,7 +4,7 @@ udp: addresses: ["239.0.60.53"] on_receive: - logger.log: - format: "Received %d bytes" + format: "Received %zu bytes" args: [data.size()] - udp.write: id: my_udp From 5d9d6e83f789b2453f748d6fa3feca206a25167f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 15:41:32 -0700 Subject: [PATCH 010/282] Bump ruff from 0.15.12 to 0.15.13 (#16437) 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 218bc0083c..9050132e70 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.12 # also change in .pre-commit-config.yaml when updating +ruff==0.15.13 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1bb191aa77b181da81880a88f65643adbdd187d1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:36 -0400 Subject: [PATCH 011/282] [ci] Skip dashboard-deprecation bot on release/beta-bump PRs (#16427) --- .github/workflows/dashboard-deprecation-comment.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml index 04a2a2151b..ffd5ec7bd9 100644 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ b/.github/workflows/dashboard-deprecation-comment.yml @@ -12,6 +12,12 @@ jobs: dashboard-deprecation-comment: name: Dashboard deprecation comment runs-on: ubuntu-latest + # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably + # roll up everything merged into dev since the last cut, which can + # include dashboard changes that have already been reviewed once. + # The bot's purpose is to warn new contributors before they invest + # time -- that only applies to PRs entering dev. + if: github.event.pull_request.base.ref == 'dev' steps: - name: Generate a token id: generate-token From 1d86d856d1153fef03f5b3185ae795a943511faa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:51:59 -0400 Subject: [PATCH 012/282] [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..25de9472b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false # Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager) +# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). +# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can +# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without +# it idf_tools.py rejects the openocd install with exit 127 and aborts +# the whole framework setup. RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ + apk add --no-cache build-base libusb; \ else \ apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ && rm -rf /var/lib/apt/lists/*; \ fi From 313d97498391084a085e9f99451928ce310f5f27 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:53:42 -0400 Subject: [PATCH 013/282] [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 ++-- .../esp32_hosted/update/esp32_hosted_update.cpp | 6 +++--- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/fastled_base/fastled_light.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/midea/air_conditioner.cpp | 4 ++-- esphome/components/ota/ota_partitions_esp_idf.cpp | 4 ++-- .../components/remote_receiver/remote_receiver.cpp | 8 ++++---- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- .../total_daily_energy/total_daily_energy.cpp | 2 +- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..7f3ba77895 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..fb0cc2e56d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..0fa69a23b4 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..7603dd5254 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f91e88bde0..a7fc709313 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address, plan.copy_dest_part->address, running_app_size); err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { @@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index d59ee63695..222dae8f7f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() { void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:\n" - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", + " Buffer Size: %" PRIu32 "\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->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, this->idle_us_); diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04426b8b1d..57709306cd 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { LastPlayedServerPref pref{.server_id_hash = hash}; bool ok = this->last_played_server_pref_.save(&pref); if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); } else { ESP_LOGW(TAG, "Failed to persist last played server hash"); } @@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) { std::optional SendspinHub::load_last_server_hash() { LastPlayedServerPref pref{}; if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); return pref.server_id_hash; } return std::nullopt; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 161c712cc1..7c9dbb604f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() { timeout_seconds = seconds_until_midnight + 1; } - ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds); this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, [this]() { this->schedule_midnight_reset_(); }); } From a92b607754c3babd607f3e689bb017955485072f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 18:54:13 -0400 Subject: [PATCH 014/282] [ci] Add ci-run-all label to force full CI matrix (#16421) --- .github/workflows/ci.yml | 39 ++++++-- script/determine-jobs.py | 79 ++++++++++++---- tests/script/test_determine_jobs.py | 139 ++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 819dac926e..abd2d1b3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,7 @@ jobs: integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} + clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }} python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} @@ -287,7 +288,12 @@ jobs: GH_TOKEN: ${{ github.token }} run: | . venv/bin/activate - output=$(python script/determine-jobs.py) + EXTRA_ARGS="" + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then + EXTRA_ARGS="--force-all" + echo "::notice::ci-run-all label detected -- forcing every CI job to run" + fi + output=$(python script/determine-jobs.py $EXTRA_ARGS) echo "Test determination output:" echo "$output" | jq @@ -296,6 +302,7 @@ jobs: echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT + echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT @@ -500,7 +507,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -512,7 +525,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} else echo "Running clang-tidy on changed files only" @@ -572,7 +585,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -584,7 +603,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy else echo "Running clang-tidy on changed files only" @@ -661,7 +680,13 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=hash_changed" >> $GITHUB_OUTPUT else @@ -673,7 +698,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} else echo "Running clang-tidy on changed files only" diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 0a55b2a848..3259fb5836 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1062,22 +1062,42 @@ def main() -> None: parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) + parser.add_argument( + "--force-all", + action="store_true", + help=( + "Force every job to run regardless of what changed. Used by CI " + "when the ci-run-all label is applied to a PR (escape hatch for " + "changes that need full-matrix validation but don't touch enough " + "files to trigger it organically)." + ), + ) args = parser.parse_args() # Determine what should run - integration_run_all, integration_test_files = determine_integration_tests( - args.branch - ) + if args.force_all: + integration_run_all, integration_test_files = True, [] + run_clang_tidy = True + run_clang_format = True + run_python_linters = True + run_import_time = True + run_device_builder = True + native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) + run_native_idf = True + else: + integration_run_all, integration_test_files = determine_integration_tests( + args.branch + ) + run_clang_tidy = should_run_clang_tidy(args.branch) + run_clang_format = should_run_clang_format(args.branch) + run_python_linters = should_run_python_linters(args.branch) + run_import_time = should_run_import_time(args.branch) + run_device_builder = should_run_device_builder(args.branch) + native_idf_components = native_idf_components_to_test(args.branch) + run_native_idf = bool(native_idf_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) - run_clang_tidy = should_run_clang_tidy(args.branch) - run_clang_format = should_run_clang_format(args.branch) - run_python_linters = should_run_python_linters(args.branch) - run_import_time = should_run_import_time(args.branch) - run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -1106,11 +1126,27 @@ def main() -> None: changed_components = changed_components_result is_core_change = False - # Filter to only components that have test files - # Components without tests shouldn't generate CI test jobs - changed_components_with_tests = [ - component for component in changed_components if _component_has_tests(component) - ] + if args.force_all: + # Force every component with tests into the CI matrix. Each disk entry + # under tests/components/ is treated as a component; filtered + # below by _component_has_tests so components without YAML tests are + # still excluded. + tests_root = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH + all_components = sorted(d.name for d in tests_root.iterdir() if d.is_dir()) + changed_components_with_tests = [ + component for component in all_components if _component_has_tests(component) + ] + # Treat as a core change so downstream logic (clang-tidy full scan, + # dep expansion) sees the same world as when esphome/core/ changes. + is_core_change = True + else: + # Filter to only components that have test files + # Components without tests shouldn't generate CI test jobs + changed_components_with_tests = [ + component + for component in changed_components + if _component_has_tests(component) + ] # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation @@ -1143,8 +1179,10 @@ def main() -> None: memory_impact = detect_memory_impact_config(args.branch) # Determine clang-tidy mode based on actual files that will be checked + is_full_scan = False if run_clang_tidy: # Full scan needed if: hash changed OR core files changed + # (is_core_change is forced True under --force-all) is_full_scan = _is_clang_tidy_full_scan() or is_core_change if is_full_scan: @@ -1177,10 +1215,12 @@ def main() -> None: # Build output # Determine which C++ unit tests to run - cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) - - # Determine if benchmarks should run - run_benchmarks = should_run_benchmarks(args.branch) + if args.force_all: + cpp_run_all, cpp_components = True, [] + run_benchmarks = True + else: + cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + run_benchmarks = should_run_benchmarks(args.branch) # Split components into batches for CI testing # This intelligently groups components with similar bus configurations @@ -1219,6 +1259,7 @@ def main() -> None: "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, + "clang_tidy_full_scan": is_full_scan, "clang_format": run_clang_format, "python_linters": run_python_linters, "import_time": run_import_time, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 9139c6e095..3fd5eada94 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2602,3 +2602,142 @@ def test_main_validate_only_excludes_transitive_components( # Only foo (directly changed, validate-only). bar is a transitive dep # and still needs compile despite no source change of its own. assert output["validate_only_components"] == ["foo"] + + +def test_main_force_all_overrides_detection( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--force-all bypasses per-feature detection and runs every job. + + Detection mocks all return False/empty (which would normally skip + everything) -- the flag must override them. Also verifies clang-tidy + goes to ``split`` (full scan) and the component-test matrix is + populated from disk rather than from changed-files. + """ + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py", "--force-all"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is True + assert output["clang_tidy"] is True + assert output["clang_tidy_mode"] == "split" + assert output["clang_tidy_full_scan"] is True + assert output["clang_format"] is True + assert output["python_linters"] is True + assert output["import_time"] is True + assert output["device_builder"] is True + assert output["native_idf"] is True + # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS + assert "esp32" in output["native_idf_components"].split(",") + assert output["cpp_unit_tests_run_all"] is True + assert output["cpp_unit_tests_components"] == [] + assert output["benchmarks"] is True + # Detection helpers must not be consulted when --force-all is set + mock_determine_integration_tests.assert_not_called() + mock_should_run_clang_tidy.assert_not_called() + mock_should_run_clang_format.assert_not_called() + mock_should_run_python_linters.assert_not_called() + mock_should_run_import_time.assert_not_called() + mock_should_run_device_builder.assert_not_called() + mock_native_idf_components_to_test.assert_not_called() + mock_determine_cpp_unit_tests.assert_not_called() + # Component matrix is populated from disk (tests/components/ in the repo) + assert output["component_test_count"] > 0 + assert len(output["component_test_batches"]) > 0 + + +def test_main_force_all_off_uses_detection( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_native_idf_components_to_test: Mock, + mock_determine_cpp_unit_tests: Mock, + mock_changed_files: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without --force-all, detection helpers drive the decision (regression guard).""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_native_idf_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = [] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + + assert output["integration_tests"] is False + assert output["clang_tidy"] is False + assert output["clang_format"] is False + assert output["python_linters"] is False + assert output["native_idf"] is False + assert output["component_test_count"] == 0 + mock_determine_integration_tests.assert_called_once() + mock_should_run_clang_tidy.assert_called_once() From 56983f414fb9316062a09bbd57ae1584297cc595 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:36 -0400 Subject: [PATCH 015/282] [espidf] Gate esp_idf_size --ng on IDF version (#16441) --- esphome/build_gen/espidf.py | 10 ++++++++-- tests/unit_tests/build_gen/test_espidf.py | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5ad2072c5b..96f84ebbd1 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,8 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import get_esp32_variant, idf_version +import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed from esphome.writer import update_storage_json @@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: variant = get_esp32_variant() idf_target = variant.lower().replace("-", "") + # 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. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + # Project-wide compile options: -D defines and -W warning flags (skip # -Wl, linker flags — those go on the src component via # target_link_options below). Emitted via idf_build_set_property so the @@ -146,7 +152,7 @@ project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 36f0442355..540dd06731 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,10 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, ) +import esphome.config_validation as cv from esphome.const import KEY_CORE from esphome.core import CORE @@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None: """Give each test its own CORE.build_path and a clean esp32 data slot.""" CORE.build_path = str(tmp_path) CORE.data.setdefault(KEY_CORE, {}) - CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}} + CORE.data[KEY_ESP32] = { + KEY_COMPONENTS: {}, + KEY_IDF_VERSION: cv.Version(5, 5, 4), + } def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: From d046dd7276747918784bb46a6f4ccb257f5e738a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 14 May 2026 22:51:14 -0400 Subject: [PATCH 016/282] [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..71d1fd3ac1 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -249,7 +249,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 814a4031c1..49c4cdbb2e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.6 + version: 2.12.7 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From c5c627d534df0f96043b40f1bf1267de6bc33b5a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:31:11 -0400 Subject: [PATCH 017/282] [audio] Bump microMP3 to v0.2.1 (#16429) --- 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 44371e87ab..13b379ba3a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.0") + add_idf_component(name="esphome/micro-mp3", ref="0.2.1") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 49c4cdbb2e..35c55cbb4d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.0 + version: 0.2.1 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From d663d80fdefdba965a08e8436ab00cc1e332b734 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 14 May 2026 23:33:36 -0400 Subject: [PATCH 018/282] [sound_level] Use RingBufferAudioSource (#16436) --- .../components/sound_level/sound_level.cpp | 58 ++++++++++--------- esphome/components/sound_level/sound_level.h | 9 +-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index fb8bfd3085..a93e396367 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -11,7 +11,7 @@ namespace esphome::sound_level { static const char *const TAG = "sound_level"; -static const uint32_t AUDIO_BUFFER_DURATION_MS = 30; +static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; // Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2 @@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() { void SoundLevelComponent::setup() { this->microphone_source_->add_data_callback([this](const std::vector &data) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() == 2) { - // ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write + if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -81,10 +80,11 @@ void SoundLevelComponent::loop() { return; } - // Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop - this->audio_buffer_->transfer_data_from_source(0); + // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). + this->audio_source_->fill(0, false); - if (this->audio_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No new audio available for processing return; } @@ -92,11 +92,11 @@ void SoundLevelComponent::loop() { const uint32_t samples_in_window = this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_); const uint32_t samples_available_to_process = - this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available()); + this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available()); const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process); // MicrophoneSource always provides int16 samples due to Python codegen settings - const int16_t *audio_data = reinterpret_cast(this->audio_buffer_->get_buffer_start()); + const int16_t *audio_data = reinterpret_cast(this->audio_source_->data()); // Process all the new audio samples for (uint32_t i = 0; i < samples_to_process; ++i) { @@ -115,9 +115,8 @@ void SoundLevelComponent::loop() { ++this->sample_count_; } - // Remove the processed samples from ``audio_buffer_`` - this->audio_buffer_->decrease_buffer_length( - this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); + // Remove the processed samples from ``audio_source_`` + this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process)); if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values @@ -158,36 +157,39 @@ void SoundLevelComponent::stop() { } bool SoundLevelComponent::start_() { - if (this->audio_buffer_ != nullptr) { + if (this->audio_source_ != nullptr) { return true; } - // Allocate a transfer buffer - this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); - if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("transfer_buffer", 15000); + const auto &stream_info = this->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + + // Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple + // of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices. + this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { + this->status_momentary_error("ring_buffer", 15000); return false; } - // Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it - this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("ring_buffer", 15000); - this->stop_(); + // Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads + // ensure multi-channel frames are never split across the ring buffer's wrap boundary. + this->audio_source_ = audio::RingBufferAudioSource::create( + temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + this->status_momentary_error("audio_source", 15000); return false; - } else { - this->ring_buffer_ = temp_ring_buffer; - this->audio_buffer_->set_source(temp_ring_buffer); } + this->ring_buffer_ = temp_ring_buffer; this->status_clear_error(); return true; } -void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); } +void SoundLevelComponent::stop_() { this->audio_source_.reset(); } } // namespace esphome::sound_level diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 4f0081a510..aabea62ca4 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -36,11 +36,12 @@ class SoundLevelComponent : public Component { void stop(); protected: - /// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which - /// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful. + /// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy + /// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the + /// ring buffer owned by ``audio_source_``. Returns true if allocations were successful. bool start_(); - /// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer) + /// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer) void stop_(); microphone::MicrophoneSource *microphone_source_{nullptr}; @@ -48,7 +49,7 @@ class SoundLevelComponent : public Component { sensor::Sensor *peak_sensor_{nullptr}; sensor::Sensor *rms_sensor_{nullptr}; - std::unique_ptr audio_buffer_; + std::unique_ptr audio_source_; std::weak_ptr ring_buffer_; int32_t squared_peak_{0}; From d832ce51cd207f3cea82b04f2a1b7d60c0b68c6e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 15 May 2026 05:42:10 +0200 Subject: [PATCH 019/282] [nextion] Replace `connect_info` vector with fixed-size field parser, always log device info (#16059) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/display.py | 21 ++++++-- esphome/components/nextion/nextion.cpp | 75 +++++++++++++++----------- esphome/components/nextion/nextion.h | 15 +++--- esphome/core/defines.h | 1 - tests/components/nextion/common.yaml | 1 - 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index dc3d5c6d09..89e9b93520 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -39,6 +41,8 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -55,6 +59,15 @@ NextionSetBrightnessAction = nextion_ns.class_( ) +def _deprecated_dump_device_info(value): + _LOGGER.warning( + "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " + "Device info is now always logged at connection time. " + "Please remove this option from your configuration." + ) + return value + + def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -81,7 +94,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean, + # Deprecated — device info is now always logged. Remove before 2026.11.0. + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( + cv.boolean, _deprecated_dump_device_info + ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( cv.positive_time_period_milliseconds, @@ -277,9 +293,6 @@ async def to_code(config): cg.add(var.set_auto_wake_on_touch(config[CONF_AUTO_WAKE_ON_TOUCH])) - if config[CONF_DUMP_DEVICE_INFO]: - cg.add_define("USE_NEXTION_CONFIG_DUMP_DEVICE_INFO") - if config[CONF_EXIT_REPARSE_ON_START]: cg.add_define("USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b644cad507..4ebc717552 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -117,30 +117,41 @@ bool Nextion::check_connect_() { ESP_LOGN(TAG, "connect: %s", response.c_str()); - size_t start; + // Parse comok response fields directly + // Format: comok ,,,,,, + size_t field_count = 0; + size_t start = 0; size_t end = 0; - std::vector connect_info; + auto copy_field = [&](char *dst, size_t cap) { + size_t len = (end == std::string::npos ? response.size() : end) - start; + size_t n = len < cap ? len : cap; + std::memcpy(dst, response.data() + start, n); + dst[n] = '\0'; + }; while ((start = response.find_first_not_of(',', end)) != std::string::npos) { end = response.find(',', start); - connect_info.push_back(response.substr(start, end - start)); + switch (field_count) { + case 2: + copy_field(this->device_model_, this->NEXTION_MODEL_MAX); + break; + case 3: + copy_field(this->firmware_version_, this->NEXTION_FW_MAX); + break; + case 5: + copy_field(this->serial_number_, this->NEXTION_SERIAL_MAX); + break; + case 6: + this->flash_size_ = static_cast(std::strtoul(response.data() + start, nullptr, 10)); + break; + default: + break; + } + ++field_count; } - this->is_detected_ = (connect_info.size() == 7); + this->is_detected_ = (field_count == 7); if (this->is_detected_) { - ESP_LOGN(TAG, "Connect info: %zu", connect_info.size()); -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - this->device_model_ = connect_info[2]; - this->firmware_version_ = connect_info[3]; - this->serial_number_ = connect_info[5]; - this->flash_size_ = connect_info[6]; -#else // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - ESP_LOGI(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n", - connect_info[2].c_str(), connect_info[3].c_str(), connect_info[5].c_str(), connect_info[6].c_str()); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + ESP_LOGN(TAG, "Connect info: %zu fields", field_count); } else { ESP_LOGE(TAG, "Bad connect value: '%s'", response.c_str()); } @@ -178,24 +189,26 @@ void Nextion::dump_config() { #ifdef USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE ESP_LOGCONFIG(TAG, " Skip handshake: YES"); #else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + if (this->is_setup()) { + ESP_LOGCONFIG(TAG, + " Device Model: %s\n" + " FW Version: %s\n" + " Serial Number: %s\n" + " Flash Size: %" PRIu32 " bytes", + this->device_model_, this->firmware_version_, this->serial_number_, this->flash_size_); + } else { + ESP_LOGCONFIG(TAG, " Device info: not yet detected"); + } ESP_LOGCONFIG(TAG, - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n" - " Max queue age: %u ms\n" - " Startup override: %u ms\n", - this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(), - this->flash_size_.c_str(), this->max_q_age_ms_, this->startup_override_ms_); -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, " Exit reparse: YES\n"); + " Exit reparse: YES\n" #endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - ESP_LOGCONFIG(TAG, + " Max queue age: %u ms\n" + " Startup override: %u ms\n" " Wake On Touch: %s\n" " Touch Timeout: %" PRIu16, - YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_); + this->max_q_age_ms_, this->startup_override_ms_, YESNO(this->connection_state_.auto_wake_on_touch_), + this->touch_sleep_timeout_); #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c62772ac75..ef030e71da 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1610,12 +1610,15 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe nextion_writer_t writer_; optional brightness_; -#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - std::string device_model_; - std::string firmware_version_; - std::string serial_number_; - std::string flash_size_; -#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO + // Device info populated from comok response (fixed-size, no heap allocation). + // Sizes derived from Nextion Upload Protocol documentation and observed hardware. + static constexpr size_t NEXTION_MODEL_MAX = 24; ///< Max observed ~18 chars from product numbering rules + static constexpr size_t NEXTION_FW_MAX = 7; ///< 'S' prefix + integer (e.g. 'S99' or `123`) + static constexpr size_t NEXTION_SERIAL_MAX = 20; ///< Consistently 16 hex chars across all documented examples + char device_model_[NEXTION_MODEL_MAX + 1]{}; + char firmware_version_[NEXTION_FW_MAX + 1]{}; + char serial_number_[NEXTION_SERIAL_MAX + 1]{}; + uint32_t flash_size_ = 0; ///< Flash size in bytes — plain integer, no string needed void remove_front_no_sensors_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 162a6034b8..ee8e89de8b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,7 +134,6 @@ #define USE_MEDIA_SOURCE #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE -#define USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START #define USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #define USE_NEXTION_MAX_COMMANDS_PER_LOOP diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index fba6a22b97..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -276,7 +276,6 @@ display: auto_wake_on_touch: true brightness: 80% command_spacing: 5ms - dump_device_info: true exit_reparse_on_start: true lambda: |- ESP_LOGD("display","Display is being tested!"); From ff968a4629915b4bf448da32773b7cc8e4204b60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 12:36:01 -0400 Subject: [PATCH 020/282] [ci] Fix sync-device-classes workflow (failing daily for weeks) (#16448) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index f69c7530f7..879d7d5e0c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,6 +30,11 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Create sync branch + # Switch off dev before pre-commit runs so the + # no-commit-to-branch hook passes (it blocks dev/release/beta). + run: git checkout -B sync/device-classes + - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -45,15 +50,27 @@ jobs: run: | python -m pip install --upgrade pip pip install -e lib/home-assistant - pip install -r requirements_test.txt pre-commit + # Project requirements are needed so pylint can resolve runtime + # imports (e.g. smpclient in esphome/components/nrf52/ota.py). + pip install -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | python ./script/sync-device_class.py - - name: Run pre-commit hooks - run: | - python script/run-in-env.py pre-commit run --all-files + - name: Apply pre-commit auto-fixes + # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify + # files. pre-commit exits non-zero whenever a hook touches anything, + # which would otherwise abort the workflow before the auto-fixes + # can flow into the sync PR. + run: python script/run-in-env.py pre-commit run --all-files || true + + - name: Verify pre-commit clean + # Second pass: re-run all hooks against the now-fixed tree. + # Auto-fixers exit 0 (nothing to change); any remaining failure + # from a check-only hook (pylint / flake8 / yamllint / ci-custom) + # is a real issue and fails the workflow loudly. + run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 From 5b6c54c961d800bbc284729463060ec008ce849d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:45:11 -0700 Subject: [PATCH 021/282] [ci] sync-device-classes: use uv for installs and skip pylint (#16449) --- .github/workflows/sync-device-classes.yml | 31 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 879d7d5e0c..7a6f272ff2 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -46,13 +46,20 @@ jobs: with: python-version: "3.14" + - name: Set up uv + # An order of magnitude faster than pip on cold boots, with its + # own wheel cache. ``--system`` (below) installs into the + # setup-python interpreter so subsequent ``pre-commit`` / + # ``script/run-in-env.py`` steps find the deps without a + # ``uv run`` prefix. + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install Home Assistant run: | - python -m pip install --upgrade pip - pip install -e lib/home-assistant - # Project requirements are needed so pylint can resolve runtime - # imports (e.g. smpclient in esphome/components/nrf52/ota.py). - pip install -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -e lib/home-assistant + uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit - name: Sync run: | @@ -63,13 +70,23 @@ jobs: # files. pre-commit exits non-zero whenever a hook touches anything, # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. + # + # pylint is skipped: this workflow only installs a subset of the + # runtime deps (HA + requirements*.txt), so pylint surfaces + # import-error / relative-beyond-top-level noise that the main CI + # already gates on real PRs. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure - # from a check-only hook (pylint / flake8 / yamllint / ci-custom) - # is a real issue and fails the workflow loudly. + # from a check-only hook (flake8 / yamllint / ci-custom) is a + # real issue and fails the workflow loudly. pylint stays skipped + # for the same reason as above. + env: + SKIP: pylint run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 1b1e21d470589a65a63e8456a8a19de88af27d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:54:01 -0700 Subject: [PATCH 022/282] [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead (#16450) --- .github/workflows/sync-device-classes.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7a6f272ff2..23a63c5d8a 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,11 +30,6 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Create sync branch - # Switch off dev before pre-commit runs so the - # no-commit-to-branch hook passes (it blocks dev/release/beta). - run: git checkout -B sync/device-classes - - name: Checkout Home Assistant uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -71,22 +66,26 @@ jobs: # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. # - # pylint is skipped: this workflow only installs a subset of the - # runtime deps (HA + requirements*.txt), so pylint surfaces - # import-error / relative-beyond-top-level noise that the main CI - # already gates on real PRs. + # SKIP: + # - no-commit-to-branch is a local guard against committing on + # dev/release/beta; CI runs on dev by definition, and + # peter-evans/create-pull-request creates the branch itself. + # - pylint surfaces import-error / relative-beyond-top-level + # noise here because this workflow installs only a subset of + # the runtime deps (HA + requirements*.txt); main CI already + # gates pylint on real PRs. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files || true - name: Verify pre-commit clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure # from a check-only hook (flake8 / yamllint / ci-custom) is a - # real issue and fails the workflow loudly. pylint stays skipped - # for the same reason as above. + # real issue and fails the workflow loudly. Same SKIP list as + # above for the same reasons. env: - SKIP: pylint + SKIP: pylint,no-commit-to-branch run: python script/run-in-env.py pre-commit run --all-files - name: Commit changes From 4189979391cb9616ca8a812da678cbb89c50e698 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:57:49 -0700 Subject: [PATCH 023/282] Synchronise Device Classes from Home Assistant (#16452) Co-authored-by: esphomebot --- esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index f076c7f17b..6bbab76363 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -96,6 +96,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, @@ -174,6 +175,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, diff --git a/esphome/const.py b/esphome/const.py index fa1ea42bc6..4557380c73 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1367,6 +1367,7 @@ DEVICE_CLASS_TEMPERATURE = "temperature" DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta" DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_UPDATE = "update" +DEVICE_CLASS_UPTIME = "uptime" DEVICE_CLASS_VIBRATION = "vibration" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" From 4381a8baaaa369ddfdda112e90337c5f87660f3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 09:59:35 -0700 Subject: [PATCH 024/282] [ci] pr-title-check: skip all bot authors, not just dependabot (#16453) --- .github/workflows/pr-title-check.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e15d09da82..e8320672d2 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -29,10 +29,11 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; - const author = context.payload.pull_request.user.login; + const user = context.payload.pull_request.user; - // Skip bot PRs (e.g. dependabot) - they have their own title format - if (author === 'dependabot[bot]') { + // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) - + // they have their own title formats. + if (user.type === 'Bot') { return; } From 8b3bc47547d33f96bb3b23e23e5596de27b847f6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 15 May 2026 19:16:01 +0200 Subject: [PATCH 025/282] [uptime] Update device_class for Uptime sensor (#16434) Co-authored-by: J. Nick Koston --- esphome/components/uptime/sensor/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..6ce0795cdb 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_UPTIME, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,9 +33,8 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, - icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=DEVICE_CLASS_UPTIME, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From ec1826a6ed47d9a94ad64b6751ec46a2088f7ce4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:17:50 -0700 Subject: [PATCH 026/282] [yaml_util] Promote include-discovery helper, share it with bundle (#16447) --- esphome/bundle.py | 97 ++----------- esphome/yaml_util.py | 125 +++++++++++++++++ tests/unit_tests/test_bundle.py | 12 +- tests/unit_tests/test_yaml_util.py | 216 +++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 91 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 70c4fad0fd..4537cbce9d 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -260,42 +260,20 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - Deliberately uses a fresh re-parse and force-loads every deferred - ``IncludeFile`` to include *all* potentially-reachable includes, - even branches not selected by the local substitutions. Bundles are - meant to be compiled on another system where command-line - substitution overrides may choose a different branch — e.g. - ``!include network/${eth_model}/config.yaml`` must ship every - candidate so the remote build can pick any one. - - Entries with unresolved substitution variables in the filename - path are skipped with a warning (they cannot be resolved without - the substitution pass). - - Secrets files are tracked separately so we can filter them to - only include the keys this config actually references. + Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a + fresh re-parse and force-loads every deferred ``IncludeFile`` so that + *all* potentially-reachable includes are captured (even branches not + selected by local substitutions). Bundles are meant to be compiled on + another system where command-line substitution overrides may choose a + different branch — e.g. ``!include network/${eth_model}/config.yaml`` + must ship every candidate so the remote build can pick any one. """ - # Must be a fresh parse: IncludeFile.load() caches its result in - # _content, and we discover files by listening for loader calls. On - # an already-parsed tree the cache is populated, .load() returns - # without calling the loader, the listener never fires, and the - # referenced files would be silently dropped from the bundle. - with yaml_util.track_yaml_loads() as loaded_files: - try: - data = yaml_util.load_yaml(self._config_path) - except EsphomeError: - _LOGGER.debug( - "Bundle: re-loading YAML for include discovery failed, " - "proceeding with partial file list" - ) - else: - _force_load_include_files(data) - - for fpath in loaded_files: - if fpath == self._config_path.resolve(): + discovered = yaml_util.discover_user_yaml_files(self._config_path) + self._secrets_paths.update(discovered.secrets) + config_resolved = self._config_path.resolve() + for fpath in discovered.files: + if fpath == config_resolved: continue # Already added as config - if fpath.name in const.SECRETS_FILES: - self._secrets_paths.add(fpath) self._add_file(fpath) def _discover_component_files(self) -> None: @@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) -def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: - """Recursively resolve any ``IncludeFile`` instances in a YAML tree. - - Nested ``!include`` returns a deferred ``IncludeFile`` that is only - resolved during the substitution pass. During bundle discovery we need - the referenced files to actually load so the ``track_yaml_loads`` - listener fires for them. - - ``IncludeFile`` instances with unresolved substitution variables in the - filename cannot be loaded — we skip and warn about those. - """ - if _seen is None: - _seen = set() - - if isinstance(obj, yaml_util.IncludeFile): - if id(obj) in _seen: - return - _seen.add(id(obj)) - if obj.has_unresolved_expressions(): - _LOGGER.warning( - "Bundle: cannot resolve !include %s (referenced from %s) " - "with substitutions in path", - obj.file, - obj.parent_file, - ) - return - try: - loaded = obj.load() - except EsphomeError as err: - _LOGGER.warning( - "Bundle: failed to load !include %s (referenced from %s): %s", - obj.file, - obj.parent_file, - err, - ) - return - _force_load_include_files(loaded, _seen) - elif isinstance(obj, dict): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for value in obj.values(): - _force_load_include_files(value, _seen) - elif isinstance(obj, (list, tuple)): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for item in obj: - _force_load_include_files(item, _seen) - - def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b153d160a7..b56d024418 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from contextlib import contextmanager, suppress +from dataclasses import dataclass, field import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -233,6 +234,130 @@ class IncludeFile: return has_substitution_or_expression(str(self.file)) +def force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + _seen: set[int] | None = None, +) -> None: + """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved + later (substitution / packages pass). Callers that need every referenced + file to actually load — bundle discovery, on-device YAML recovery — invoke + this while a :func:`track_yaml_loads` listener is active so the underlying + loader fires and records every reachable file. + + ``IncludeFile`` instances whose path contains unresolved substitution + variables cannot be loaded. By default a warning is logged for each one; + pass ``warn_on_unresolved=False`` (used by discovery paths that run on a + fresh re-parse where substitutions haven't been applied yet) to demote it + to a debug log. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug + log( + "Cannot resolve !include %s (referenced from %s) with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + force_load_include_files( + loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + force_load_include_files( + value, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + force_load_include_files( + item, warn_on_unresolved=warn_on_unresolved, _seen=_seen + ) + + +@dataclass(slots=True) +class DiscoveredYamlFiles: + """Result of :func:`discover_user_yaml_files`. + + ``files`` contains every resolved path the YAML loader touched while we + were re-parsing the user's config; ``secrets`` is the subset whose + *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so + a ``secrets.yaml`` symlinked to a differently-named target is still + flagged as secrets). + """ + + files: list[Path] = field(default_factory=list) + secrets: set[Path] = field(default_factory=set) + + +def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: + """Fresh-re-parse ``config_path`` and report every file the YAML loader + pulled in, plus which of them came in under a secrets filename. + + Does NOT run schema validation, substitutions, or package resolution — so + component-internal YAML loaded by validators (LVGL helpers, dashboard + imports, etc.) is *not* captured. Deferred ``!include`` references whose + paths don't depend on substitutions are force-loaded here so they're + captured too. + + Must run on a fresh parse because :meth:`IncludeFile.load` caches its + result; on an already-resolved tree :meth:`load` returns without invoking + the loader and the listener would not fire for the referenced files. + """ + from esphome.const import SECRETS_FILES + + secrets: set[Path] = set() + + def _capture_secret(fname: Path) -> None: + if Path(fname).name in SECRETS_FILES: + secrets.add(Path(fname).resolve()) + + with track_yaml_loads() as loaded: + _load_listeners.append(_capture_secret) + try: + try: + data = load_yaml(config_path) + except EsphomeError: + return DiscoveredYamlFiles(list(loaded), secrets) + force_load_include_files(data, warn_on_unresolved=False) + finally: + _load_listeners.remove(_capture_secret) + + # Deduplicate while preserving first-seen order. + seen: set[Path] = set() + unique: list[Path] = [] + for path in loaded: + if path not in seen: + seen.add(path) + unique.append(path) + return DiscoveredYamlFiles(unique, secrets) + + def _add_data_ref(fn): @functools.wraps(fn) def wrapped(loader, node): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 5d046252da..f15bbf2e29 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,13 +22,13 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, - _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, ) from esphome.core import CORE, EsphomeError +from esphome.yaml_util import force_load_include_files # --------------------------------------------------------------------------- # Helpers @@ -947,7 +947,7 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths assert any( - "failed to load !include" in r.message and "missing.yaml" in r.message + "failed to load !include" in r.message.lower() and "missing.yaml" in r.message for r in caplog.records ) @@ -974,8 +974,8 @@ def test_force_load_skips_duplicate_include_file() -> None: # Same instance appears twice — second visit must hit the _seen guard. tree = {"a": stub, "b": [stub]} - with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): - _force_load_include_files(tree) + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + force_load_include_files(tree) assert stub.load_calls == 1 @@ -989,8 +989,8 @@ def test_force_load_handles_cyclic_containers() -> None: cyclic_list.append(cyclic_list) # Should return without recursing forever - _force_load_include_files(cyclic_dict) - _force_load_include_files(cyclic_list) + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) def test_discover_files_yaml_reload_failure( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index ace92fbf6f..e97a188be4 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -12,11 +12,15 @@ import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict from esphome.yaml_util import ( + DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + discover_user_yaml_files, + force_load_include_files, format_path, make_data_base, make_literal, + track_yaml_loads, ) @@ -966,3 +970,215 @@ def test_make_literal_blocks_substitution() -> None: # undefined in the context. assert result == {"pin": "${PIN}"} assert isinstance(result, ESPLiteralValue) + + +# --------------------------------------------------------------------------- +# force_load_include_files / discover_user_yaml_files +# --------------------------------------------------------------------------- + + +class _StubInclude: + """Stand-in for `IncludeFile` that records how `load()` was called. + + Patched in via `esphome.yaml_util.IncludeFile` so the recursion in + `force_load_include_files` treats instances as deferred includes without + needing an actual on-disk file. + """ + + def __init__( + self, + file: str = "stub.yaml", + parent_file: Path | None = None, + *, + unresolved: bool = False, + load_result: object = None, + raise_on_load: EsphomeError | None = None, + ) -> None: + self.file = Path(file) + self.parent_file = parent_file or Path("/tmp/parent.yaml") + self._unresolved = unresolved + self._load_result = load_result if load_result is not None else {} + self._raise = raise_on_load + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return self._unresolved + + def load(self) -> object: + self.load_calls += 1 + if self._raise is not None: + raise self._raise + return self._load_result + + +@pytest.fixture +def patch_include_file(): + """Replace `IncludeFile` with `_StubInclude` so isinstance checks in + `force_load_include_files` match the stubs constructed by tests.""" + with patch("esphome.yaml_util.IncludeFile", _StubInclude): + yield + + +def test_force_load_include_files_resolves_nested_includes( + patch_include_file: None, +) -> None: + """A tree of dict/list/IncludeFile is walked and every IncludeFile is loaded.""" + inner = _StubInclude("inner.yaml") + outer = _StubInclude("outer.yaml", load_result={"nested": inner}) + force_load_include_files([{"a": outer}, "scalar"]) + assert outer.load_calls == 1 + assert inner.load_calls == 1 + + +def test_force_load_include_files_seen_guard_prevents_double_load( + patch_include_file: None, +) -> None: + """The same IncludeFile referenced from two branches loads once.""" + stub = _StubInclude("once.yaml") + force_load_include_files({"a": stub, "b": [stub]}) + assert stub.load_calls == 1 + + +def test_force_load_include_files_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't trigger infinite recursion.""" + cyclic_dict: dict[str, object] = {} + cyclic_dict["self"] = cyclic_dict + cyclic_list: list[object] = [] + cyclic_list.append(cyclic_list) + # Both calls must return without recursing forever. + force_load_include_files(cyclic_dict) + force_load_include_files(cyclic_list) + + +@pytest.mark.parametrize( + ("warn_on_unresolved", "expect_level"), + [ + pytest.param(True, "WARNING", id="default-warns"), + pytest.param(False, "DEBUG", id="opt-in-demotes"), + ], +) +def test_force_load_include_files_unresolved_log_level( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, + warn_on_unresolved: bool, + expect_level: str, +) -> None: + """Substitution-templated include paths skip the load and log at the + level chosen by `warn_on_unresolved`.""" + stub = _StubInclude("${var}.yaml", unresolved=True) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}, warn_on_unresolved=warn_on_unresolved) + assert stub.load_calls == 0 + matching = [ + r.levelname for r in caplog.records if "Cannot resolve !include" in r.message + ] + assert matching == [expect_level] + + +def test_force_load_include_files_warns_on_load_failure( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("WARNING", logger="esphome.yaml_util"): + force_load_include_files({"k": stub}) + assert any( + "Failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_discovered_yaml_files_holds_files_and_secrets() -> None: + """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + files = [Path("/tmp/a.yaml")] + secrets = {Path("/tmp/a.yaml")} + discovered = DiscoveredYamlFiles(files, secrets) + assert discovered.files is files + assert discovered.secrets is secrets + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + """Write `content` to `tmp_path/name`, creating parent dirs as needed.""" + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +def _write_entry_including(tmp_path: Path, included_name: str) -> Path: + """Write a minimal entry yaml that `!include`s `included_name`.""" + return _write( + tmp_path, + "entry.yaml", + f"esphome:\n name: test\nwifi: !include {included_name}\n", + ) + + +def test_discover_user_yaml_files_captures_includes(tmp_path: Path) -> None: + """A `!include` in the entry yaml is force-loaded so the listener fires.""" + _write(tmp_path, "wifi.yaml", "ssid: my_ssid\npassword: my_pw\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, "wifi.yaml")) + names = {p.name for p in discovered.files} + assert names == {"entry.yaml", "wifi.yaml"} + assert discovered.secrets == set() + + +@pytest.mark.parametrize( + "secret_name", + [ + pytest.param("secrets.yaml", id="yaml"), + pytest.param("secrets.yml", id="yml"), + ], +) +def test_discover_user_yaml_files_flags_secrets_filename( + tmp_path: Path, secret_name: str +) -> None: + """Both `secrets.yaml` and `secrets.yml` get flagged in `.secrets`.""" + _write(tmp_path, secret_name, "key: value\n") + discovered = discover_user_yaml_files(_write_entry_including(tmp_path, secret_name)) + assert (tmp_path / secret_name).resolve() in discovered.secrets + + +def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: + """`secrets.yaml` symlinked to a non-secrets-named target is still flagged + because the un-resolved basename is what gets recorded.""" + target = _write(tmp_path, "real_creds.yaml", "key: value\n") + (tmp_path / "secrets.yaml").symlink_to(target) + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "secrets.yaml") + ) + # The recorded "secret path" is the resolved target — even though its + # basename is `real_creds.yaml`, it's still in `.secrets`. + assert target.resolve() in discovered.secrets + + +def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure returns whatever was tracked so far without raising.""" + entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") + discovered = discover_user_yaml_files(entry) + assert isinstance(discovered, DiscoveredYamlFiles) + + +def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: + """The same file referenced twice appears once in `.files`.""" + _write(tmp_path, "wifi.yaml", "ssid: a\n") + entry = _write( + tmp_path, + "entry.yaml", + "esphome:\n name: test\nwifi: !include wifi.yaml\nfoo: !include wifi.yaml\n", + ) + discovered = discover_user_yaml_files(entry) + wifi_resolved = (tmp_path / "wifi.yaml").resolve() + assert discovered.files.count(wifi_resolved) == 1 + + +def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: + """`track_yaml_loads` is the building block — sanity-check it resolves + symlinks so callers can dedupe by identity.""" + target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n") + link = tmp_path / "alias.yaml" + link.symlink_to(target) + with track_yaml_loads() as loaded: + yaml_util.load_yaml(link) + assert target.resolve() in loaded From 46be0f4f62141a6dc5bfe7b55acb753b2b64c343 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:07 -0700 Subject: [PATCH 027/282] [ci] Log top 30 pytest durations (#16455) --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd2d1b3a5..57c896fe19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,7 +181,7 @@ jobs: # own CI). No ``--cov`` here -- this is purely a downstream # smoke check against this PR's esphome code. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks pytest: name: Run pytest @@ -222,12 +222,12 @@ jobs: if: matrix.os == 'windows-latest' run: | . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: @@ -370,7 +370,7 @@ jobs: . venv/bin/activate mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" cpp-unit-tests: name: Run C++ unit tests From 1674ed9744414bc00f91a8ae5b9255dbeac2f19f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:18:27 -0700 Subject: [PATCH 028/282] [ci] Use uv for pip installs across CI workflows (#16451) --- .github/actions/restore-python/action.yml | 16 ++++++++++++---- .github/workflows/ci-api-proto.yml | 8 +++++++- .github/workflows/ci.yml | 22 ++++++++++++++++++---- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 21393f2aba..751f9ecf58 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -27,6 +27,14 @@ runs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash @@ -34,8 +42,8 @@ runs: python -m venv venv source venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' shell: bash @@ -43,5 +51,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2f7fd271ba..1dc0ccb7fe 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -26,6 +26,12 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" + - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Install apt dependencies run: | @@ -34,7 +40,7 @@ jobs: sudo apt install -y protobuf-compiler protoc --version - name: Install python dependencies - run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt + run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py - name: Check for changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c896fe19..de21456841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,22 @@ jobs: path: venv # yamllint disable-line rule:line-length key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + uv pip install -e . pylint: name: Check pylint @@ -351,14 +359,20 @@ jobs: with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Register matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests From 96106d25bc4555423ce7a05f2a8ee0888ed2641e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:15 -0700 Subject: [PATCH 029/282] [wifi] Refuse to compile when wifi_ssid is the device-builder placeholder (#16444) --- esphome/__main__.py | 8 +++ esphome/components/wifi/__init__.py | 52 +++++++++++++++- esphome/const.py | 9 +++ tests/unit_tests/components/test_wifi.py | 78 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d733534a5c..16a05ad552 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -50,6 +50,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USERNAME, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_PLATFORM, @@ -733,6 +734,13 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bad57fc481..f9cb391442 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,10 +54,18 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + HexInt, + coroutine_with_priority, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, } ) + + +def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: + """Return human-readable locations where the dashboard's placeholder wifi + values still appear. Empty list means no placeholders were found. + """ + placeholders: list[str] = [] + wifi_conf = config.get(CONF_WIFI) + if not wifi_conf: + return placeholders + + for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])): + ssid = network.get(CONF_SSID) + if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append(f"wifi.networks[{idx}].ssid") + + ap_conf = wifi_conf.get(CONF_AP) + if ap_conf: + ap_ssid = ap_conf.get(CONF_SSID) + if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID: + placeholders.append("wifi.ap.ssid") + + return placeholders + + +def check_placeholder_credentials(config: ConfigType) -> None: + """Raise EsphomeError if any wifi credential is the dashboard placeholder. + + Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, + or any path reached by `esphome config`; device-builder relies on + validation passing with the placeholders still in place. + """ + locations = _placeholder_wifi_credentials(config) + if not locations: + return + formatted = ", ".join(locations) + raise EsphomeError( + f"wifi configuration still contains the dashboard placeholder value " + f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. " + f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') " + f"with your real wifi credentials before flashing." + ) diff --git a/esphome/const.py b/esphome/const.py index 4557380c73..9dd77a7cb8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1416,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" # The corresponding constant exists in c++ # when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds SCHEDULER_DONT_RUN = 4294967295 + +# Sentinel values written by the esphome-device-builder dashboard into +# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password +# references resolve cleanly through validation before the user has finished +# the onboarding wizard. Compilation refuses if these reach the binary so that +# a user who dismisses onboarding can't accidentally flash a device that will +# never associate with their wifi. +PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK" +PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105 diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 71a14d7817..9598c1bdd8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -3,8 +3,20 @@ import pytest from esphome.components.esp32 import const -from esphome.components.wifi import has_native_wifi, variant_has_wifi -from esphome.const import Platform +from esphome.components.wifi import ( + check_placeholder_credentials, + has_native_wifi, + variant_has_wifi, +) +from esphome.const import ( + CONF_AP, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, + PLACEHOLDER_WIFI_SSID, + Platform, +) +from esphome.core import EsphomeError, Lambda @pytest.mark.parametrize( @@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" assert has_native_wifi(platform=Platform.RP2040) is True + + +def _wifi_config( + *, + networks: list[dict] | None = None, + ap: dict | None = None, +) -> dict: + """Build a minimal config dict matching the post-validation shape.""" + wifi: dict = {} + if networks is not None: + wifi[CONF_NETWORKS] = networks + if ap is not None: + wifi[CONF_AP] = ap + return {CONF_WIFI: wifi} + + +def test_check_placeholder_credentials_passes_with_real_ssid() -> None: + """A real SSID compiles without complaint.""" + config = _wifi_config(networks=[{CONF_SSID: "home_network"}]) + assert check_placeholder_credentials(config) is None + + +def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None: + """The placeholder SSID is rejected with an actionable message.""" + config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}]) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + message = str(exc_info.value) + assert "wifi.networks[0].ssid" in message + assert "secrets.yaml" in message + + +def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None: + """Index reporting picks the placeholder out of a mixed network list.""" + config = _wifi_config( + networks=[ + {CONF_SSID: "home_network"}, + {CONF_SSID: PLACEHOLDER_WIFI_SSID}, + ], + ) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.networks[1].ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None: + """An AP using the placeholder broadcast name is also refused.""" + config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID}) + with pytest.raises(EsphomeError) as exc_info: + check_placeholder_credentials(config) + assert "wifi.ap.ssid" in str(exc_info.value) + + +def test_check_placeholder_credentials_no_wifi_passes() -> None: + """Ethernet-only / wifi-less configs skip the check entirely.""" + assert check_placeholder_credentials({}) is None + + +def test_check_placeholder_credentials_skips_template_ssid() -> None: + """A templated (Lambda) SSID is not a string and is skipped.""" + config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) + assert check_placeholder_credentials(config) is None From 35631be260c0fd6fae1e4c945f16790979ba777c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 030/282] [writer] Mark storage_should_clean as public API for device-builder (#16443) --- esphome/writer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 72c2c355dc..ad3877465d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl): def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: + """Return True when the build tree must be wiped before reuse. + + Predicate is True when *old* is missing (first build), + ``src_version`` differs, ``build_path`` differs, or a previously + loaded integration was removed in *new*. Adding integrations or + changing unrelated fields (friendly name, esphome version, etc.) + does not trigger a clean. + + Used by esphome-device-builder (esphome/device-builder) to gate + its remote-build artifact materialiser so a local → remote → local + cycle preserves PlatformIO's local object cache instead of wiping + it on every cycle. The signature, semantics, and ``None`` handling + for *old* are part of the public contract; keep them stable so the + offloader's wipe decision tracks core's. + """ if old is None: return True From 47eb2adbf2c789d62405d69954d468ddb51d40a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:29:15 -0700 Subject: [PATCH 031/282] [core] Fix KeyError: 'esp32' on upload when validated-config cache is used (#16457) --- esphome/storage_json.py | 13 +++++- tests/unit_tests/test_compiled_config.py | 56 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7d26b22f96..e481827080 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -273,10 +273,21 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { - KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(), + KEY_TARGET_PLATFORM: target_platform, KEY_TARGET_FRAMEWORK: self.framework, } + # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's + # validator runs; on the cache fast path that validator is skipped, + # so populate the variant upload_using_esptool reads via + # esp32.get_esp32_variant(). target_platform on disk is the variant + # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + if target_platform == const.PLATFORM_ESP32: + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import KEY_VARIANT + + CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 34e811b97b..8c9cfa8101 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -22,6 +22,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, ) from esphome.core import CORE @@ -47,7 +48,12 @@ wifi: """ -def _write_storage(storage_path: Path) -> None: +def _write_storage( + storage_path: Path, + *, + esp_platform: str = "ESP32", + core_platform: str | None = "esp32", +) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) data = { @@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None: "src_version": 1, "address": "192.168.1.42", "web_port": None, - "esp_platform": "ESP32", + "esp_platform": esp_platform, "build_path": "/build/lite_test", "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], "no_mdns": False, "framework": "arduino", - "core_platform": "esp32", + "core_platform": core_platform, } storage_path.write_text(json.dumps(data)) @@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" + # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. + from esphome.components.esp32.const import KEY_ESP32 + + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" + + +def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: + """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3" + + +def test_load_compiled_config_skips_esp32_block_for_other_platforms( + tmp_path: Path, +) -> None: + """Non-esp32 targets shouldn't fabricate an esp32 data block.""" + from esphome.components.esp32.const import KEY_ESP32 + + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage( + storage_dir / "lite_test.yaml.json", + esp_platform="ESP8266", + core_platform="esp8266", + ) + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=5) + + assert load_compiled_config(yaml_path) is not None + assert KEY_ESP32 not in CORE.data @pytest.mark.parametrize( From 65d6bb18ed09c4d916ed7c6f1ad98eae2284a34b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 15 May 2026 13:32:51 -0400 Subject: [PATCH 032/282] [esp32_hosted][fingerprint_grow] Fix two remaining ESP32 toolchain warnings (#16442) --- esphome/components/esp32_hosted/update/esp32_hosted_update.cpp | 2 +- esphome/components/fingerprint_grow/fingerprint_grow.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7f3ba77895..70fa41b312 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() { } } else { ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", - app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD); + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } From fb70095ba194b3d01f1a0801dd2cfdec9d7e0290 Mon Sep 17 00:00:00 2001 From: david-collett Date: Sat, 16 May 2026 03:47:26 +1000 Subject: [PATCH 033/282] [esp32_ble_server] Fix incorrect BLECharacteristic read truncation (#16420) (#16422) Co-authored-by: Dave Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_server/ble_characteristic.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index cc519846be..842c78a8aa 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -218,13 +218,14 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } else { response.attr_value.offset = 0; - if (this->value_.size() + 1 > max_offset) { - response.attr_value.len = max_offset; - this->value_read_offset_ = max_offset; - } else { - response.attr_value.len = this->value_.size(); + response.attr_value.len = this->value_.size(); + if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, + ESP_GATT_MAX_ATTR_LEN); + response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; } memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); + this->value_read_offset_ = 0; } response.attr_value.handle = this->handle_; From 59f8c1019f23b603344014ddba4258e95f4e30c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:28 -0700 Subject: [PATCH 034/282] Bump pytest-codspeed from 5.0.1 to 5.0.2 (#16459) 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 9050132e70..ea4941a882 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.1 +pytest-codspeed==5.0.2 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From ec6669fa679ffed9d371c42ad8d3c9ee935b0bb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:41 -0700 Subject: [PATCH 035/282] Bump requests from 2.34.1 to 2.34.2 (#16460) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6291b5cd41..a0a7ea5674 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.34.1 +requests==2.34.2 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c090c6b8512adf16cf45fc0b71a031bb0b12407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:56 -0700 Subject: [PATCH 036/282] Bump github/codeql-action from 4.35.4 to 4.35.5 (#16461) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a4dd9a92d..fbef0f5157 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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: "/language:${{matrix.language}}" From b78b78cbbbf1ee8ca65ef6ff563faadb449fe2ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:42:51 -0700 Subject: [PATCH 037/282] Bump aioesphomeapi from 45.0.0 to 45.0.1 (#16467) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a0a7ea5674..33a6c5b555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.0 +aioesphomeapi==45.0.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From ff34e1061bc9ce55e39ff61f04a57a0258e94c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:43:08 -0700 Subject: [PATCH 038/282] Bump resvg-py from 0.3.1 to 0.3.2 (#16466) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 33a6c5b555..1996de0928 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.1 +resvg-py==0.3.2 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 1a287bf785af01ce50b52dcecc5a938babd75a01 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:04 +1000 Subject: [PATCH 039/282] [ft5x06] Fix setting calibration values (#16446) --- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + 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(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - 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 FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } From df100681e0286ad13557a81027d46f962a21f349 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 16 May 2026 08:36:53 +1000 Subject: [PATCH 040/282] [lvgl] Fix image define (#16468) --- esphome/components/lvgl/__init__.py | 5 +++++ esphome/components/lvgl/lvgl_esphome.h | 10 ++++++---- esphome/components/lvgl/styles.py | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..4277c14dd7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, LOGGER, + add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( + BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, STYLE_REMAP, @@ -100,6 +102,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -433,6 +436,8 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): + add_lv_use(CONF_IMAGE) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 218f9a60ab..3f7f1dce14 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..5911505555 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,6 +9,7 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, get_theme_widget_map, literal, ) @@ -25,6 +26,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): From 48d17571c82d8a048d263595865d2cf15bce2225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 15:50:58 -0700 Subject: [PATCH 041/282] [tests] Mock determine_cpp_unit_tests in clang_tidy_mode tests (#16456) --- tests/script/test_determine_jobs.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 3fd5eada94..202ae9030f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1518,6 +1518,7 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1529,6 +1530,9 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files mock_changed_files.return_value = [] @@ -1584,6 +1588,7 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -1595,6 +1600,9 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + # Without this mock, main() runs the real determine_cpp_unit_tests + # which loads the full component graph (~5s import of every component). + mock_determine_cpp_unit_tests.return_value = (False, []) # Create component names components = [f"comp{i}" for i in range(component_count)] @@ -2651,6 +2659,15 @@ def test_main_force_all_overrides_detection( return_value={"should_run": "false"}, ), patch.object(determine_jobs, "should_run_benchmarks", return_value=False), + # create_intelligent_batches scans every tests/components//*.yaml + # under --force-all (~2500 YAML loads, ~10s in CI). This test only + # asserts that main() routes to it and returns non-empty -- the + # batching logic itself has its own dedicated tests. + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["fake_batch"]], None), + ), ): determine_jobs.main() From fb0bfea1c8955d24667da80872845598778d4865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 22:06:33 -0700 Subject: [PATCH 042/282] Bump aioesphomeapi from 45.0.1 to 45.0.2 (#16469) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1996de0928..d117e47d91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.1 +aioesphomeapi==45.0.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 7c5d5f75dc378683b02e645f435153c3891a3226 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 22:16:52 -0700 Subject: [PATCH 043/282] [ci] Use larger app partition for esp32-s3-idf component test grouping (#16430) --- .../build_components_base.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml index ee209000e9..f3122f977e 100644 --- a/tests/test_build_components/build_components_base.esp32-s3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-s3-idf.yaml @@ -7,6 +7,9 @@ esp32: variant: ESP32S3 framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE From 01c0d3163e4f0eedd4efc29dbf8668d9b8ff4d2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 18:41:09 -0700 Subject: [PATCH 044/282] Bump aioesphomeapi from 45.0.2 to 45.0.3 (#16479) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d117e47d91..eddc403820 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.2 +aioesphomeapi==45.0.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 155232875a671e903e62565764784fbb50bcd79e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 19:08:19 -0700 Subject: [PATCH 045/282] Bump zeroconf from 0.148.0 to 0.149.3 (#16480) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eddc403820..6eef8ac643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.148.0 +zeroconf==0.149.3 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 66907258603a2e96a71f85242bef096515b05705 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:38 -0400 Subject: [PATCH 046/282] [espidf] Switch direct framework downloader to esphome-libs/esp-idf tarballs (#16484) --- esphome/espidf/framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7ff373aba8..32bcf4fb3b 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -69,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get( "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", ) ) From 42ad2a627281c4826f14312c18eecae0666fc066 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 10:49:03 +1200 Subject: [PATCH 047/282] [espidf] Accept list input in _str_to_lst_of_str helper (#16485) --- esphome/espidf/framework.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..8996ff1e02 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -26,16 +26,18 @@ _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str) -> list[str]: +def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string Args: - a: A string containing semicolon-separated values + a: A string containing semicolon-separated values, or an already-split list Returns: list of strings """ + if isinstance(a, list): + return a return list(f.strip() for f in a.split(";") if f.strip()) @@ -67,10 +69,11 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( ) ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( - os.environ.get( - "ESPHOME_IDF_FRAMEWORK_MIRRORS", - "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", - ) + os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") + or [ + "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + ] ) ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( From 9c696f5de1f86671a983ab322e72a5e06921c7d5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 18 May 2026 15:56:44 +1200 Subject: [PATCH 048/282] [ci] Move ha-addon and schema release triggers to version-notifier (#16490) --- .github/workflows/release.yml | 70 +---------------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1086c858c..9799f882db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,74 +212,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -302,7 +234,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", From edb59476b1841b932fbb74754659f885dc58d31f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:29:03 -0700 Subject: [PATCH 049/282] Bump zeroconf from 0.149.3 to 0.149.7 (#16492) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6eef8ac643..e3de4a134c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.3 +zeroconf==0.149.7 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From b0af4a9f0dd86a7aae573d9e8cb4ea22130fb915 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:48 -0500 Subject: [PATCH 050/282] [sen5x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16463) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index ce35cf5bf1..480654ee1b 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -77,7 +76,6 @@ def _gas_sensor( return sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend( { From cb581271edd4b7a2ad41538af5792e706bd3e5c2 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:19:51 -0500 Subject: [PATCH 051/282] [sgp4x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16464) --- esphome/components/sgp4x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1e58a0f26a..d407f20a4e 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, ) @@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, From 36fc36071d44ff9f23668fa9209f207eeff615cb Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:08 -0500 Subject: [PATCH 052/282] [sen6x] Remove incorrect AQI device class from VOC and NOx Index sensors (#16465) --- esphome/components/sen6x/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 071478e719..19c0cb500e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, - DEVICE_CLASS_AQI, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -93,13 +92,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_VOC): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CO2): sensor.sensor_schema( From 7ecfe4b5c9326690b7d852b4ff2cc891bb80e40e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 18 May 2026 22:53:19 -0400 Subject: [PATCH 053/282] [i2s_audio] Compute ring buffer size with SPDIF sample count (#16400) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 21 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 1 - .../speaker/i2s_audio_speaker_standard.cpp | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..877f67775b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. - const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // 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 } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 20bb05e322..34792bdbea 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; 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 e69601e87a..ffe901504d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -16,6 +16,7 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t DMA_BUFFERS_COUNT = 4; // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep From c0e71fc713702f04ff858fd381621803c19ae750 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:36 +0200 Subject: [PATCH 054/282] [zigbee] don't allow zigbee + thread or access point (#16499) --- esphome/components/zigbee/__init__.py | 2 ++ esphome/components/zigbee/zigbee_esp32.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 69e3fe9c5a..c75b0773d2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +CONFLICTS_WITH = ["openthread"] + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index e446377a06..89efd583ab 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if not CORE.is_esp32: return config if CONF_WIFI in fv.full_config.get(): - if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: - raise cv.Invalid( - "Only Zigbee End Device can be used together with a Wifi Access Point." - ) if CONF_AP in fv.full_config.get()[CONF_WIFI]: - _LOGGER.warning( - "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + raise cv.Invalid( + "A Wifi Access Point can not be used together with Zigbee." ) - elif config[CONF_ROUTER]: + if config[CONF_ROUTER]: _LOGGER.warning( "The Zigbee Router might miss packets while Wifi is active and could destabilize " "your network. Use only if Wifi is off most of the time." From 1d0ddfac5d6abed8c13bc9c9c05c9a1066126155 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:18 -0400 Subject: [PATCH 055/282] [espidf] Print RAM summary on ESP32-S3 / unified-DIRAM variants (#16494) --- esphome/espidf/size_summary.py | 7 +- tests/unit_tests/test_size_summary.py | 128 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_size_summary.py diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 9477e664b3..3ba0bf3b4d 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s", e) return - dram = data.get("memory_types", {}).get("DRAM") or {} - ram_used = dram.get("used") - ram_total = dram.get("size") + 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(f"RAM: {_format_bar(ram_used, ram_total)}") diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py new file mode 100644 index 0000000000..933be88476 --- /dev/null +++ b/tests/unit_tests/test_size_summary.py @@ -0,0 +1,128 @@ +"""Tests for esphome.espidf.size_summary.print_summary.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from esphome.espidf.size_summary import print_summary + + +def _write_size_json(tmp_path: Path, data: dict) -> Path: + """Drop a fake esp_idf_size.json under ``tmp_path`` and return the path.""" + out = tmp_path / "esp_idf_size.json" + out.write_text(json.dumps(data)) + return out + + +def _esp32_size_data() -> dict: + """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + return { + "image_size": 827455, + "memory_types": { + "DRAM": { + "size": 180736, + "used": 47332, + "sections": { + ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, + ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + }, + }, + "IRAM": { + "size": 131072, + "used": 80351, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 79323}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +def _s3_size_data() -> dict: + """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + return { + "image_size": 724215, + "memory_types": { + "DIRAM": { + "size": 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}, + }, + }, + "IRAM": { + "size": 16384, + "used": 16384, + "sections": { + ".iram0.text": {"abbrev_name": ".text", "size": 15356}, + ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + }, + }, + }, + } + + +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.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" in out + assert "used 47332 bytes from 180736 bytes" in out + + +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.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + print_summary(size_json, partitions_csv=None) + 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] +) -> 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": {}, + }, + }, + }, + ) + print_summary(size_json, partitions_csv=None) + out = capsys.readouterr().out + assert "RAM:" not in out + + +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) + assert capsys.readouterr().out == "" + + +def test_print_summary_handles_no_memory_types( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> 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) + assert capsys.readouterr().out == "" From 80ed54103257bab0ea1ba76caee56003ee5673e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:13:58 -0700 Subject: [PATCH 056/282] [core] Add progmem_memcpy HAL helper (#16470) --- esphome/components/esp8266/hal.h | 6 ++++++ esphome/core/hal.h | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index effa9c9371..f3b33da692 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers +// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on +// ESP8266 is ~4x as many flash accesses as the bulk path. +__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) { + memcpy_P(dst, src, len); // NOLINT +} // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4babda807d..b44a422836 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -1,6 +1,7 @@ #pragma once -#include #include +#include +#include #include "gpio.h" #include "esphome/core/defines.h" #include "esphome/core/time_64.h" @@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart(); inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } +// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a +// plain `std::memcpy` is correct and the fast path here. +inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); } #endif } // namespace esphome From 863af482ecd4d1afbdd7c31bdcaea92f02431955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 10:14:15 -0700 Subject: [PATCH 057/282] [esp32_ble_server] Honor client offset and MTU in long reads (#16458) --- .../esp32_ble_server/ble_characteristic.cpp | 48 ++++++++----------- .../esp32_ble_server/ble_characteristic.h | 1 - 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 842c78a8aa..4d364b4655 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -196,43 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt (*this->on_read_callback_)(param->read.conn_id); } - uint16_t max_offset = 22; - + // Use the client-supplied offset for long reads; short reads always start at 0. + // The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we + // just provide as much data as we have from the requested offset and let the stack + // handle framing. The client issues subsequent blob reads with increasing offsets + // until it has received the whole value. + const uint16_t offset = param->read.is_long ? param->read.offset : 0; + esp_gatt_status_t status = ESP_GATT_OK; esp_gatt_rsp_t response; - if (param->read.is_long) { - if (this->value_read_offset_ >= this->value_.size()) { - response.attr_value.len = 0; - response.attr_value.offset = this->value_read_offset_; - this->value_read_offset_ = 0; - } else if (this->value_.size() - this->value_read_offset_ < max_offset) { - // Last message in the chain - response.attr_value.len = this->value_.size() - this->value_read_offset_; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ = 0; - } else { - response.attr_value.len = max_offset; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ += max_offset; - } + response.attr_value.offset = offset; + + if (offset > this->value_.size()) { + status = ESP_GATT_INVALID_OFFSET; + response.attr_value.len = 0; } else { - response.attr_value.offset = 0; - response.attr_value.len = this->value_.size(); - if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) { - ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len, - ESP_GATT_MAX_ATTR_LEN); - response.attr_value.len = ESP_GATT_MAX_ATTR_LEN; + size_t remaining = this->value_.size() - offset; + if (remaining > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", + static_cast(remaining), ESP_GATT_MAX_ATTR_LEN); + remaining = ESP_GATT_MAX_ATTR_LEN; } - memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); - this->value_read_offset_ = 0; + response.attr_value.len = remaining; + memcpy(response.attr_value.value, this->value_.data() + offset, remaining); } response.attr_value.handle = this->handle_; response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE; esp_err_t err = - esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response); + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err); } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 94c7495cbd..933177a399 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -79,7 +79,6 @@ class BLECharacteristic { esp_gatt_char_prop_t properties_; uint16_t handle_{0xFFFF}; - uint16_t value_read_offset_{0}; std::vector value_; std::vector descriptors_; From e979d461f01ca69f0715d687217609d6d4060994 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 12:15:50 -0500 Subject: [PATCH 058/282] Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#16500) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de21456841..dbbb06c86c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 9924d998f1dbc42bb868c16f794e9238d34e70c7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 14:37:41 -0400 Subject: [PATCH 059/282] [i2s_audio] Optimize SPDIF encoder and suport higher bit depth audio (#16504) Co-authored-by: Keith Burzinski --- .../components/i2s_audio/speaker/__init__.py | 7 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 12 +- .../i2s_audio/speaker/spdif_encoder.cpp | 537 +++++++++++------- .../i2s_audio/speaker/spdif_encoder.h | 59 +- .../common-spdif_mode.yaml} | 11 - .../test-spdif_speaker.esp32-idf.yaml | 8 + 6 files changed, 372 insertions(+), 262 deletions(-) rename tests/components/{speaker/spdif_mode.esp32-idf.yaml => i2s_audio/common-spdif_mode.yaml} (52%) create mode 100644 tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..8215d8b518 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -89,10 +89,10 @@ def _set_num_channels_from_config(config): def _set_stream_limits(config): if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), @@ -213,9 +213,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 877f67775b..989bcf2977 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -411,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -420,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment diff --git a/tests/components/speaker/spdif_mode.esp32-idf.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml similarity index 52% rename from tests/components/speaker/spdif_mode.esp32-idf.yaml rename to tests/components/i2s_audio/common-spdif_mode.yaml index 4d6859feae..374a4bce1e 100644 --- a/tests/components/speaker/spdif_mode.esp32-idf.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -1,13 +1,3 @@ -substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 - spdif_data_pin: GPIO4 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - i2s_audio: - id: i2s_output @@ -20,6 +10,5 @@ speaker: use_apll: true timeout: 2s sample_rate: 48000 - bits_per_sample: 16bit channel: stereo i2s_mode: primary diff --git a/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml new file mode 100644 index 0000000000..a69d808d1d --- /dev/null +++ b/tests/components/i2s_audio/test-spdif_speaker.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO12 + spdif_data_pin: GPIO4 + +<<: !include common-spdif_mode.yaml From 09121226344c26f530dee36e78041616ac8d381f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 19 May 2026 15:16:00 -0400 Subject: [PATCH 060/282] [sendspin] Bump sendspin to v0.6.0 (#16496) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 35280020ba..36f13f7d07 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 35c55cbb4d..42d0d5de6b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.5.0 + version: 0.6.0 lvgl/lvgl: version: 9.5.0 From 8927ade7897904f84fad29d8cb446e7fd62fcafb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 15:40:20 +0000 Subject: [PATCH 061/282] Bump zeroconf from 0.149.7 to 0.149.12 (#16510) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e3de4a134c..21005fe85f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.3 -zeroconf==0.149.7 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From fbe212944b01f7e768ade5edd7d259394566dde1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:51:53 -0500 Subject: [PATCH 062/282] Bump aioesphomeapi from 45.0.3 to 45.0.4 (#16513) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 21005fe85f..cfb1960ea1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 +aioesphomeapi==45.0.4 zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 60afad442c83309ccfd8203e61689b263023f07c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 13:36:18 -0400 Subject: [PATCH 063/282] [esp32] Fix sdkconfig int values silently clamped to default (#16515) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..fd28d80536 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2004,7 +2004,8 @@ async def to_code(config): if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: - add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + # Kconfig range is [1,63]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1) _configure_lwip_max_sockets(conf) @@ -2251,7 +2252,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) elif advanced[CONF_DISABLE_FATFS]: add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) + # Kconfig range is [1,10]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) From 52c9a2d07bcde57f0ccc5fde9f9f20bc97e1c3f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:58 -0400 Subject: [PATCH 064/282] [espidf] Drop version field from generated idf_component.yml (#16511) --- esphome/components/esp32/__init__.py | 3 +- esphome/espidf/component.py | 66 ++++------------------- tests/unit_tests/test_espidf_component.py | 26 ++++++--- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index fd28d80536..8bc8a71c94 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2490,9 +2490,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): From 870f628637cbd9c2256c4c7f694ee8c575574d48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:59 -0400 Subject: [PATCH 065/282] [esp32] Decouple esp-idf toolchain version check from PIO, honor framework source: override (#16516) --- esphome/components/esp32/__init__.py | 85 +++++++++++++++-------- esphome/espidf/framework.py | 22 ++++-- esphome/espidf/toolchain.py | 17 ++++- tests/unit_tests/test_espidf_toolchain.py | 58 ++++++++++++++++ 4 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_espidf_toolchain.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8bc8a71c94..5cf2129051 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 8996ff1e02..3dcb9dd242 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -789,6 +789,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -799,6 +800,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -820,6 +826,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -847,9 +857,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1011,6 +1020,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1021,6 +1031,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1043,7 +1057,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None) From 43a1c2067edb9031acb1f58b37bd41f8f96ed42a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 08:28:09 -0500 Subject: [PATCH 066/282] Bump zeroconf from 0.149.12 to 0.149.13 (#16520) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfb1960ea1..4daed8971e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.12 +zeroconf==0.149.13 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From a70e358ceadd63022831744b0bbe44174c40fd65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:28 -0500 Subject: [PATCH 067/282] Bump zeroconf from 0.149.13 to 0.149.16 (#16533) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4daed8971e..178e05497f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.0.4 -zeroconf==0.149.13 +zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 52e7d3ccfb680257f94c2bd72653e4e0c74c595d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:25 -0400 Subject: [PATCH 068/282] [esp32] Use new sdkconfig key names that replaced deprecated ones (#16522) --- esphome/components/esp32/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5cf2129051..09d2e89bd3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1987,7 +1987,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True) # Setup watchdog - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) @@ -2122,7 +2122,6 @@ async def to_code(config): for key, flag in ASSERTION_LEVELS.items(): add_idf_sdkconfig_option(flag, assertion_level == key) - add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) From 90715373f2b074ffd66db674b382d8c9abc41e10 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:35:51 -0400 Subject: [PATCH 069/282] [espidf] Filter noisy 'git rev-parse' errors when .git is stripped (#16521) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 65df37c7b2..da3f77cdd3 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [ # Drop the blank line rich emits after the note so the build log # doesn't end with an orphan gap before ESPHome's own status lines. r"\s*$", + # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; + # esphome-libs strips ``.git`` from the tarball so those probes fail + # noisily without affecting the build. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", ] From f2bfe5cd178d297ec4e1c99d61e9abf45feb5409 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:36:27 -0400 Subject: [PATCH 070/282] [espidf] Fix tarfile extract crashing on Python 3.11 with None mode (#16530) --- esphome/espidf/framework.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 3dcb9dd242..f20391c6a5 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -549,11 +549,11 @@ def _tar_extract_all( if not (mode & stat.S_IXUSR): mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) mode |= stat.S_IRUSR | stat.S_IWUSR - elif member.isdir() or member.issym(): - # Ignore mode for directories & symlinks - mode = None - else: - # Block special files + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). continue member.mode = mode From b619e3e8c77ec710af69726013218ce3b2b1fa1d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:10 -0400 Subject: [PATCH 071/282] [espidf] Write version.txt after extract so bootloader shows the real version (#16532) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f20391c6a5..a967f7c5af 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,6 +783,34 @@ def download_from_mirrors( return None +def _write_idf_version_txt(framework_path: Path, version: str) -> None: + """Write /version.txt if missing. + + IDF's build.cmake picks the version it embeds in the firmware (and + stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt`` + if present, else ``git describe`` against IDF_PATH, else the + ``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``. + On a clean esphome-libs tarball ``.git`` is fully stripped, so + git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet + wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom + framework.source pointed at a real git URL where build artifacts + mark the tree dirty) makes git_describe return ``-dirty``, + which is what then gets baked into the bootloader. Dropping + version.txt forces the right answer regardless. + """ + version_txt = framework_path / "version.txt" + if version_txt.exists(): + return + try: + version_txt.write_text(f"v{version}\n", encoding="utf-8") + except OSError as e: + _LOGGER.warning( + "Could not write %s (%s); bootloader version string may be incorrect.", + version_txt, + e, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -864,6 +892,11 @@ def _check_esphome_idf_framework_install( archive_extract_all(tmp.file, framework_path, progress_header="Extracting") extracted_marker.touch() + # Idempotent post-extract patch: written every invocation so a build + # dir extracted before this fix gets the file too, without forcing a + # clean. Skips when version.txt already exists. + _write_idf_version_txt(framework_path, version) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From e0076cb1a8b60657e24332844420ef321f25147c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 11:37:46 -0400 Subject: [PATCH 072/282] [core] Persist & restore CORE.toolchain through StorageJSON (#16531) --- esphome/storage_json.py | 20 ++++++++ tests/unit_tests/test_storage_json.py | 73 ++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index e481827080..7f8885ba5f 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -14,6 +14,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed @@ -98,6 +99,7 @@ class StorageJSON: no_mdns: bool, framework: str | None = None, core_platform: str | None = None, + toolchain: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -134,6 +136,8 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. self.core_platform = core_platform + # The toolchain used for the build ("platformio" / "esp-idf") + self.toolchain = toolchain def as_dict(self): return { @@ -153,6 +157,7 @@ class StorageJSON: "no_mdns": self.no_mdns, "framework": self.framework, "core_platform": self.core_platform, + "toolchain": self.toolchain, } def to_json(self): @@ -189,6 +194,7 @@ class StorageJSON: ), framework=esph.target_framework, core_platform=esph.target_platform, + toolchain=esph.toolchain.value if esph.toolchain is not None else None, ) @staticmethod @@ -236,6 +242,7 @@ class StorageJSON: no_mdns = storage.get("no_mdns", False) framework = storage.get("framework") core_platform = storage.get("core_platform") + toolchain = storage.get("toolchain") return StorageJSON( storage_version, name, @@ -253,6 +260,7 @@ class StorageJSON: no_mdns, framework, core_platform, + toolchain, ) @staticmethod @@ -273,6 +281,18 @@ class StorageJSON: """ CORE.name = self.name CORE.build_path = self.build_path + # Restore toolchain so upload/logs picks the right firmware_bin path. + # An unknown value (corrupt sidecar, or written by a newer ESPHome) + # just leaves CORE.toolchain None — the fallback then picks PlatformIO. + if self.toolchain and CORE.toolchain is None: + try: + CORE.toolchain = Toolchain(self.toolchain) + except ValueError: + _LOGGER.debug( + "Ignoring unknown toolchain %r from %s", + self.toolchain, + storage_path(), + ) target_platform = self.core_platform or self.target_platform.lower() CORE.data[KEY_CORE] = { KEY_TARGET_PLATFORM: target_platform, diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index a3a38960e7..ea37492cf4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json -from esphome.const import CONF_DISABLED, CONF_MDNS +from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" + mock_core.toolchain = Toolchain.ESP_IDF with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" + assert result.toolchain == "esp-idf" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" + mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False + assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: @@ -470,6 +474,73 @@ def test_storage_json_equality() -> None: assert storage1 != "not a storage object" +def _make_storage_with_toolchain( + toolchain: str | None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="ESP32", + build_path=Path("/build"), + firmware_bin_path=Path("/build/firmware.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + toolchain=toolchain, + ) + + +def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: + """Sidecar toolchain survives save -> load -> apply_to_core.""" + storage = _make_storage_with_toolchain("esp-idf") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + # Serialization key is stable -- device-builder relies on it. + assert json.loads(path.read_text())["toolchain"] == "esp-idf" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.toolchain == "esp-idf" + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.ESP_IDF + + +def test_storage_json_apply_to_core_preserves_cli_toolchain( + setup_core: Path, +) -> None: + """A CLI-set CORE.toolchain wins over the sidecar value.""" + loaded = _make_storage_with_toolchain("esp-idf") + + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_storage_json_apply_to_core_ignores_unknown_toolchain( + setup_core: Path, +) -> None: + """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" + loaded = _make_storage_with_toolchain("gcc") + + CORE.toolchain = None + with patch("esphome.components.esp32.get_esp32_variant"): + loaded.apply_to_core() + assert CORE.toolchain is None + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( From 233a60f1062a6d344c3d19f030658a162e6940d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 10:53:34 -0500 Subject: [PATCH 073/282] [ci] Pin uv version in setup-uv to fix Windows manifest fetch flake (#16534) --- .github/actions/restore-python/action.yml | 4 ++++ .github/workflows/ci-api-proto.yml | 4 ++++ .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/sync-device-classes.yml | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 751f9ecf58..03b4803860 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -35,6 +35,10 @@ runs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1dc0ccb7fe..675bbe9d2c 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -32,6 +32,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install apt dependencies run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbb06c86c..43b03aec85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -175,6 +179,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install device-builder + esphome from PR # Install device-builder with its esphome + test extras # first so its pinned versions of pytest/etc. land, then @@ -365,6 +373,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 23a63c5d8a..84be3c8e22 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -50,6 +50,10 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" - name: Install Home Assistant run: | From 01494f7431ad2c1056d1eff0c0b2fd6cc7d50227 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 21 May 2026 11:57:32 -0400 Subject: [PATCH 074/282] [audio] Bump esp-audio-libs to v3.1.0 (#16519) --- 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 13b379ba3a..c9775ab601 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.0.0", + ref="3.1.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 42d0d5de6b..44c63e46cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.0.0 + version: 3.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 750ae56778699dd727da51f5363592d543f97c1c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:27 -0400 Subject: [PATCH 075/282] [espidf] Backport ninja linux-arm64 entry into tools.json on aarch64 hosts (#16527) --- esphome/espidf/framework.py | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index a967f7c5af..079c97cc98 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -7,6 +7,7 @@ import json import logging import os from pathlib import Path +import platform import shutil import subprocess import sys @@ -17,7 +18,7 @@ import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree +from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed PathType = str | os.PathLike @@ -811,6 +812,74 @@ def _write_idf_version_txt(framework_path: Path, version: str) -> None: ) +# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release +# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no +# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a +# matching binary or grabs the x86_64 one, which can't execute on +# aarch64. cmake is already populated across the same release range; we +# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1 +# tools.json where the fix landed natively. +_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = { + "1.12.1": { + "rename_dist": "ninja-linux-arm64-v1.12.1.zip", + "sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", + "size": 121787, + "url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip", + }, +} + + +def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: + """Inject ninja linux-arm64 entries into the framework's tools.json on aarch64. + + Idempotent: a tools.json that already has the entry, or a host that + isn't aarch64, is a no-op. Applied unconditionally on every install + check so a build dir extracted before the backport got fixed up + without forcing a clean. + """ + if platform.machine() != "aarch64": + return + + tools_json = framework_path / "tools" / "tools.json" + if not tools_json.is_file(): + return + + try: + with open(tools_json, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.warning( + "Could not parse %s for linux-arm64 backport (%s); " + "skipping. A clean reinstall of the framework directory " + "may be needed.", + tools_json, + e, + ) + return + + changed = False + for tool in data.get("tools", []): + if tool.get("name") != "ninja": + continue + for ver in tool.get("versions", []): + entry = _NINJA_ARM64_BACKPORT.get(ver.get("name")) + if entry is None or ver.get("linux-arm64"): + continue + ver["linux-arm64"] = entry + changed = True + + if changed: + # write_file_if_changed stages a tempfile in the destination dir + # and atomically replaces — safe against mid-write interruption + # and concurrent invocations. + write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n") + _LOGGER.info( + "Patched %s to add ninja linux-arm64 download " + "(espressif/esp-idf#18272 backport).", + tools_json, + ) + + def _check_esphome_idf_framework_install( version: str, targets: list[str], @@ -897,6 +966,11 @@ def _check_esphome_idf_framework_install( # clean. Skips when version.txt already exists. _write_idf_version_txt(framework_path, version) + # Apply the ninja linux-arm64 backport on every invocation, not just on + # fresh extracts — idempotent and cheap, and lets a build dir carrying + # a pre-patch tools.json get fixed up without forcing a clean. + _patch_tools_json_for_linux_arm64(framework_path) + # 3. Check if the framework tools are the same and correctly installed if not install: install = True From 3719ea740a577659ec8e2837372e3f7cc553f1ef Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:19 -0400 Subject: [PATCH 076/282] [espidf] Default to remote HEAD when cg.add_library URL has no #ref (#16535) --- esphome/espidf/component.py | 15 ++++++++------- tests/unit_tests/test_espidf_component.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b1352f7791..7d9874ad5f 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -93,7 +93,7 @@ class URLSource(Source): class GitSource(Source): - def __init__(self, url: str, ref: str): + def __init__(self, url: str, ref: str | None): self.url = url self.ref = ref @@ -109,7 +109,7 @@ class GitSource(Source): return path def __str__(self): - return f"{self.url}#{self.ref}" + return f"{self.url}#{self.ref}" if self.ref else self.url class InvalidIDFComponent(Exception): @@ -352,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent: IDFComponent: The resolved component with name, version, and URL Raises: - ValueError: If a repository URL is missing a reference (#) RuntimeError: If no artifact can be found for the library """ name = None @@ -362,10 +361,11 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: # Parse repository URL: path becomes the component name, fragment - # becomes the git ref stored on GitSource. + # (if any) becomes the git ref stored on GitSource. A missing + # fragment is fine -- clone_or_update leaves the depth-1 clone on + # the remote's default branch, matching PIO's lib_deps behavior + # and external_components handling. split_result = urlsplit(library.repository) - if not split_result.fragment.strip(): - raise ValueError(f"Missing ref in URL {library.repository}") # Sanitize name name = str(split_result.path).strip("/") @@ -377,7 +377,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: version = "*" repository = urlunsplit(split_result._replace(fragment="")) - source = GitSource(str(repository), split_result.fragment) + ref = split_result.fragment.strip() or None + source = GitSource(str(repository), ref) # Version is provided - resolve using PlatformIO registry elif library.version: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 373432f7d2..c4a419d1a2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -436,11 +436,21 @@ def test_convert_library_with_branch_ref(): assert result.source.ref == "some-branch" -def test_convert_library_missing_ref(): +def test_convert_library_missing_ref_uses_default_branch(): + """A bare URL with no #ref clones the remote's default branch. + + Matches PIO's lib_deps behavior and external_components handling -- + git.clone_or_update with ref=None leaves the depth-1 clone on + whatever branch the remote HEAD points at. + """ lib = Library("name", None, "https://github.com/foo/bar.git") - with pytest.raises(ValueError): - _convert_library_to_component(lib) + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref is None def test_convert_library_registry(monkeypatch): From 56fd77e4c8480551525527de8196de17c64d05ed Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:54 -0400 Subject: [PATCH 077/282] [espidf] Honor the dict shorthand for library.json dependencies (#16537) --- esphome/espidf/component.py | 20 ++++ tests/unit_tests/test_espidf_component.py | 110 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7d9874ad5f..a452a3f34a 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -656,6 +656,26 @@ def _process_dependencies(component: IDFComponent): if not dependencies: return + # PIO's library.json accepts both the list-of-dicts form and the + # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize + # the dict form so the loop below sees a uniform list. Iterating a + # dict gives string keys, which would silently fail the + # ``"name" in dependency`` substring check and skip every entry. + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + dependencies = normalized + _LOGGER.info("Processing %s@%s component dependencies...", name, version) for dependency in dependencies: # Validate dependency structure diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a419d1a2..7d6c861ffd 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -505,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component): _process_dependencies(tmp_component) assert tmp_component.dependencies == [] + + +def test_process_dependencies_dict_form(tmp_component, monkeypatch): + """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. + + Iterating a dict gives string keys, which would silently fail the + ``"name" in dependency`` substring check. Normalize to list-of-dicts + first so the dict form (used by e.g. tesla-ble for its nanopb dep) + is treated the same as the verbose list form. + """ + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": "^0.4.91", + "BareName": "1.2.3", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(tmp_component.dependencies) == 2 + names = sorted(lib.name for lib in captured) + versions = sorted(lib.version for lib in captured) + assert names == ["BareName", "nanopb/Nanopb"] + assert versions == ["1.2.3", "^0.4.91"] + + +def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): + """A dict-value that's a URL gets routed to ``repository`` like the list form.""" + captured: list[Library] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) + + tmp_component.data = { + "dependencies": { + "foo/Bar": "https://github.com/foo/bar.git#main", + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "foo/Bar" + assert captured[0].version is None + assert captured[0].repository == "https://github.com/foo/bar.git#main" + + +def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): + """A dict-value that's itself a dict is merged into the entry. + + PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` + for entries that need fields beyond just a version (platforms, + frameworks, etc.). The extra fields flow into _check_library_data + via the entry merge. + """ + captured: list[Library] = [] + checked: list[dict] = [] + + def fake_generate(library): + captured.append(library) + return IDFComponent( + library.name, library.version, source=URLSource("http://dummy.com") + ) + + tmp_component.data = { + "dependencies": { + "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, + } + } + monkeypatch.setattr( + esphome.espidf.component, "_generate_idf_component", fake_generate + ) + monkeypatch.setattr( + esphome.espidf.component, + "_check_library_data", + checked.append, + ) + + _process_dependencies(tmp_component) + + assert len(captured) == 1 + assert captured[0].name == "nanopb/Nanopb" + assert captured[0].version == "^0.4.91" + # Extra spec fields reach _check_library_data so platform/framework + # gating still applies. + assert checked == [ + { + "name": "Nanopb", + "owner": "nanopb", + "version": "^0.4.91", + "platforms": "espidf", + } + ] From d2bda0a402f309418a0ba82e542632e12e8035d6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:03:55 -0400 Subject: [PATCH 078/282] [esp32] Defer esp_panic_handler wrap so arduino-esp32 IDF component skips it (#16538) --- esphome/components/esp32/__init__.py | 35 +++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 09d2e89bd3..274cc6fdb3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -1743,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1802,11 +1828,8 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # 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]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -2567,7 +2590,7 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) From 1ea95264bd91d073cfba8d0ce7bca0ec8fa96062 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 14:08:09 -0400 Subject: [PATCH 079/282] [tuya] Restore null guard on status_pin lost in #16353 (#16539) --- esphome/components/tuya/tuya.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index fd14844908..3058d82cc4 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff if (this->status_pin_reported_ != -1) { this->init_state_ = TuyaInitState::INIT_DATAPOINT; this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); - bool is_pin_equals = - this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; - // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (!is_pin_equals) { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", + if (this->status_pin_ != nullptr) { + if (this->status_pin_->get_pin() != this->status_pin_reported_) { + ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.", + this->status_pin_reported_); + } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + } else { + ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); } - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); From 96eced0378882f9b2d2ebb3810d87cad255bf7f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 15:42:57 -0500 Subject: [PATCH 080/282] [api] Break api_connection/api_server include cycle to drop custom unique_ptr deleter (#16542) --- esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_connection.h | 51 ++++-------------- .../components/api/api_connection_buffer.h | 54 +++++++++++++++++++ esphome/components/api/api_server.cpp | 5 -- esphome/components/api/api_server.h | 14 ++--- .../bluetooth_proxy/bluetooth_proxy.cpp | 1 + 6 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 esphome/components/api/api_connection_buffer.h diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..f2bf3752fa 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..804cd9ddd1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,7 +11,8 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #ifdef USE_ESP32_CRASH_HANDLER @@ -36,6 +37,9 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase { // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase { // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..1dd8a162e4 --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection methods that need APIServer complete. Include this +// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..c30bd2e612 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } -// Custom deleter defined here so `delete` sees the complete APIConnection type. -// This prevents libc++ from emitting an "incomplete type" error when other -// translation units only have the forward declaration of APIConnection. -void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } - void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6b575e536d..fbc8115091 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -12,8 +14,6 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -191,15 +191,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect - // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not - // eagerly instantiate `delete static_cast(p)` here, where - // only the forward declaration of APIConnection is visible (incomplete type). - struct APIConnectionDeleter { - void operator()(APIConnection *p) const; - }; - using APIConnectionPtr = std::unique_ptr; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..ca30aab943 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,5 +1,6 @@ #include "bluetooth_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" From 38b8b41ccc9b4c4d1a963ae98b6308d9a34fe988 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 21 May 2026 18:03:07 -0400 Subject: [PATCH 081/282] [sx126x] Assert NSS before wait_busy so commands wake the chip from sleep (#16546) --- esphome/components/sx126x/sx126x.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); From aea1e4d136cf2d35a3e694f9666bfab67dacd797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 17:05:17 -0500 Subject: [PATCH 082/282] [core] Refresh compiled config cache after upload/logs fallback (#16548) --- esphome/__main__.py | 15 +++- tests/unit_tests/test_compiled_config.py | 100 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. Skip when the storage + # sidecar is absent (no compile has run): the cache would + # never be loaded back, so writing secrets to disk is wasted. + if cache_eligible and config is not None: + from esphome.compiled_config import save_compiled_config + from esphome.storage_json import ext_storage_path + + if ext_storage_path(conf_path.name).exists(): + save_compiled_config(config) if config is None: return 2 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() +def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( + tmp_path: Path, +) -> None: + """Without a StorageJSON sidecar (no compile has run), the fallback + skips the cache write -- load_compiled_config requires the sidecar, + so writing the rendered (secret-resolved) YAML would be inert and + leak secrets to disk for nothing.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( + tmp_path: Path, command: str +) -> None: + """A stale-cache fallback rewrites the cache so the next call hits + the fast path. Without this, every upload/logs after a YAML edit + pays for read_config() until the next compile rewrites the cache.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + _set_cache_mtime(cache, yaml_path, offset=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {command: lambda args, config: 0}, + ), + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + + mock_save.assert_called_once_with(fresh_config) + # mtime is now newer than the source YAML, so a follow-up call hits + # the fast path instead of repeating read_config. + assert cache.stat().st_mtime >= yaml_path.stat().st_mtime + + +def test_run_esphome_upload_with_substitution_does_not_refresh_cache( + fresh_cache_files: Path, +) -> None: + """`-s` substitutions skip the cache on both read and write -- saving + here would clobber the cache with a substitution-specific config.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"upload": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + +def test_run_esphome_compile_does_not_refresh_cache_via_fallback( + fresh_cache_files: Path, +) -> None: + """Compile writes the cache through update_storage_json, not via the + upload/logs fallback path -- the fallback save would skip the + storage_should_clean check.""" + with ( + patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", + {"compile": lambda args, config: 0}, + ), + ): + run_esphome(["esphome", "compile", str(fresh_cache_files)]) + + mock_save.assert_not_called() + + def test_run_esphome_upload_with_substitution_skips_cache( fresh_cache_files: Path, ) -> None: From 4ff8eb4b15c64422b79ac618524b0c8b39d04485 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 22:08:56 +0000 Subject: [PATCH 083/282] Bump ruff from 0.15.13 to 0.15.14 (#16543) Co-authored-by: J. Nick Koston 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 da5fb94d5e..0470a948f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.12 + rev: v0.15.14 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index ea4941a882..dbdea1d935 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.13 # also change in .pre-commit-config.yaml when updating +ruff==0.15.14 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 1d3eea098e317b5c6bf9d6656a26f5b0825fa46a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:00:22 +1200 Subject: [PATCH 084/282] [core] Support YAML frontmatter for arbitrary user metadata (#16552) --- esphome/core/__init__.py | 9 +- esphome/yaml_util.py | 29 +++++- tests/unit_tests/test_yaml_util.py | 158 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e13d5668af..580d7f6477 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -5,7 +5,7 @@ import math import os from pathlib import Path import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from esphome.const import ( CONF_COMMENT, @@ -569,6 +569,12 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # YAML frontmatter loaded from user YAML files. Frontmatter is a leading + # YAML document separated by `---` from the actual configuration. It is + # ignored by config validation and code generation, but kept here so it + # can be inspected by callers (tooling, future features). Keyed by the + # resolved Path of the source file. + self.frontmatter: dict[Path, Any] = {} # The pending tasks in the task queue (mostly for C++ generation) # This is a priority queue (with heapq) # Each item is a tuple of form: (-priority, unique number, task) @@ -634,6 +640,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 self.variables = {} diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b56d024418..9a36ad089c 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -768,10 +768,35 @@ def _load_yaml_internal_with_type( content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], ) -> Any: - """Load a YAML file.""" + """Load a YAML file. + + Supports an optional leading YAML frontmatter document: when the file + contains two YAML documents separated by ``---``, the first document is + treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the + resolved file path, while the second document is returned as the actual + configuration. Frontmatter is ignored by config validation and code + generation. + """ loader = loader_type(content, fname, yaml_loader) try: - return loader.get_single_data() or OrderedDict() + documents: list[Any] = [] + while loader.check_data(): + documents.append(loader.get_data()) + if len(documents) > 2: + raise EsphomeError( + f"YAML file '{fname}' contains {len(documents)} documents but " + f"at most two are supported (an optional frontmatter document " + f"followed by the configuration)." + ) + if len(documents) == 2: + frontmatter = documents[0] + config = documents[1] + if frontmatter is not None: + CORE.frontmatter[Path(fname).resolve()] = frontmatter + return config if config is not None else OrderedDict() + if len(documents) == 1: + return documents[0] or OrderedDict() + return OrderedDict() except yaml.YAMLError as exc: raise EsphomeError(exc) from exc finally: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e97a188be4..de70a5307d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -34,6 +34,14 @@ def clear_secrets_cache() -> None: yaml_util._SECRET_CACHE.clear() +@pytest.fixture(autouse=True) +def clear_core_frontmatter() -> None: + """Reset CORE.frontmatter between tests.""" + core.CORE.frontmatter = {} + yield + core.CORE.frontmatter = {} + + def test_include_with_vars(fixture_path: Path) -> None: yaml_file = fixture_path / "yaml_util" / "includetest.yaml" @@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: with track_yaml_loads() as loaded: yaml_util.load_yaml(link) assert target.resolve() in loaded + + +# --------------------------------------------------------------------------- +# YAML frontmatter +# --------------------------------------------------------------------------- + + +def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: + """A leading `---`-separated YAML document is stored as frontmatter and + stripped from the returned config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n" + ) + + config = yaml_util.load_yaml(yaml_file) + + # Config does not contain frontmatter keys + assert "author" not in config + assert "labels" not in config + assert config["esphome"]["name"] == "my_node" + + # Frontmatter is stored on CORE keyed by resolved path + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["author"] == "Jesse" + assert frontmatter["labels"] == ["office", "climate"] + + +def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None: + """A YAML file with a single document does not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("esphome:\n name: my_node\n") + + yaml_util.load_yaml(yaml_file) + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None: + """A leading `---` with no content above it is just a document start marker, + not frontmatter, and must not populate CORE.frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\nesphome:\n name: my_node\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "my_node" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: + """Frontmatter keys are not validated — any structure is accepted.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "any_key: any_value\n" + "nested:\n" + " count: 42\n" + " items:\n" + " - a\n" + " - b\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + frontmatter = core.CORE.frontmatter[yaml_file.resolve()] + assert frontmatter["any_key"] == "any_value" + assert frontmatter["nested"]["count"] == 42 + assert frontmatter["nested"]["items"] == ["a", "b"] + + +def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: + """Frontmatter preserves deeply nested dict/list structures intact.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "device:\n" + " metadata:\n" + " location:\n" + " building: HQ\n" + " floor: 3\n" + " room:\n" + " number: 302\n" + " occupants:\n" + " - name: Jesse\n" + " role:\n" + " title: maintainer\n" + " since: 2021\n" + " - name: Alice\n" + " role:\n" + " title: contributor\n" + " since: 2024\n" + "---\n" + "esphome:\n" + " name: t\n" + ) + + yaml_util.load_yaml(yaml_file) + fm = core.CORE.frontmatter[yaml_file.resolve()] + room = fm["device"]["metadata"]["location"]["room"] + assert room["number"] == 302 + assert room["occupants"][0]["name"] == "Jesse" + assert room["occupants"][0]["role"]["title"] == "maintainer" + assert room["occupants"][0]["role"]["since"] == 2021 + assert room["occupants"][1]["role"]["title"] == "contributor" + + +def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None: + """Three or more YAML documents is unsupported and must raise.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n") + + with pytest.raises(EsphomeError, match="at most two are supported"): + yaml_util.load_yaml(yaml_file) + + +def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None: + """An empty (null) frontmatter document is treated as no frontmatter.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("---\n---\nesphome:\n name: t\n") + + config = yaml_util.load_yaml(yaml_file) + assert config["esphome"]["name"] == "t" + assert yaml_file.resolve() not in core.CORE.frontmatter + + +def test_frontmatter_empty_config_doc(tmp_path: Path) -> None: + """An empty config document after a frontmatter document yields an empty config.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("only: frontmatter\n---\n") + + config = yaml_util.load_yaml(yaml_file) + assert config == {} + assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter" + + +def test_frontmatter_included_file_stored(tmp_path: Path) -> None: + """Frontmatter on an !include'd file is also captured on CORE, keyed by + that file's resolved path.""" + inc = tmp_path / "child.yaml" + inc.write_text("child_meta: hello\n---\nchild_key: value\n") + main = tmp_path / "main.yaml" + main.write_text("esphome:\n name: t\nchild: !include child.yaml\n") + + config = yaml_util.load_yaml(main) + # !include is deferred; force resolution so the child file actually loads + force_load_include_files(config) + assert config["child"].load()["child_key"] == "value" + # Main file has no frontmatter + assert main.resolve() not in core.CORE.frontmatter + # Included file's frontmatter is captured + assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" From 0b2eb6481f4ab2c73b0b85430093c1f6a9a33810 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:50 +1200 Subject: [PATCH 085/282] [light] Add light.effect.next / light.effect.previous actions (#16491) --- esphome/components/light/__init__.py | 33 ++++- esphome/components/light/automation.h | 41 ++++++ esphome/components/light/automation.py | 76 ++++++++++- esphome/components/light/types.py | 1 + .../light/test_effect_validation.py | 127 +++++++++++++++++- tests/components/light/common.yaml | 10 ++ 6 files changed, 283 insertions(+), 5 deletions(-) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9540c64486..68d9f85af2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -86,10 +86,22 @@ class EffectRef: component_path: list[str | int] # path_context when the action was validated +@dataclass +class EffectCycleRef: + """A pending light.effect.next/previous action to validate. + + Records that the referenced light needs at least one effect configured. + """ + + light_id: ID + component_path: list[str | int] + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr effect_refs: list[EffectRef] = field(default_factory=list) + effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list) def _get_data() -> LightData: @@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType: this never runs — but the ID validator will catch the missing light ID separately. """ data = _get_data() - if not data.effect_refs: + if not data.effect_refs and not data.effect_cycle_refs: return config - # Drain the list so we only validate once even though + # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. refs = data.effect_refs data.effect_refs = [] + cycle_refs = data.effect_cycle_refs + data.effect_cycle_refs = [] fconf = fv.full_config.get() @@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) + for ref in cycle_refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + continue + + if not light_config.get(CONF_EFFECTS): + raise cv.FinalExternalInvalid( + f"Light '{ref.light_id}' has no effects configured, but a " + f"'light.effect.next' or 'light.effect.previous' action " + f"references it. Add at least one effect to the light.", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + return config diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 993d4a2ea6..260414f033 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -104,6 +104,47 @@ template class DimRelativeAction : pub transition_length_{}; }; +// Cycle through the light's configured effects. `Forward` selects direction +// at compile time so the chosen branch is the only one that gets instantiated +// per action site. `include_none` is runtime so a single set of templates +// covers both the "wrap through None" and "skip None" variants. +template class LightEffectCycleAction : public Action { + public: + explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} + + void set_include_none(bool include_none) { this->include_none_ = include_none; } + + void play(const Ts &...) override { + size_t count = this->parent_->get_effect_count(); + if (count == 0) { + return; + } + uint32_t current = this->parent_->get_current_effect_index(); + uint32_t next; + if (this->include_none_) { + uint32_t total = static_cast(count) + 1; + if constexpr (Forward) { + next = (current + 1) % total; + } else { + next = (current + total - 1) % total; + } + } else { + if constexpr (Forward) { + next = (current % static_cast(count)) + 1; + } else { + next = (current <= 1) ? static_cast(count) : current - 1; + } + } + auto call = this->parent_->turn_on(); + call.set_effect(next); + call.perform(); + } + + protected: + LightState *parent_; + bool include_none_{false}; +}; + template class LightIsOnCondition : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index cef774af38..7eaba9b117 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -26,8 +26,8 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -39,12 +39,15 @@ from .types import ( DimRelativeAction, LightCall, LightControlAction, + LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, LightState, ToggleAction, ) +CONF_INCLUDE_NONE = "include_none" + @automation.register_action( "light.toggle", @@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: + """Record a cycle-action reference for later validation against the target light.""" + from . import EffectCycleRef, _get_data + + _get_data().effect_cycle_refs.append( + EffectCycleRef( + light_id=config[CONF_ID], + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LightState), + cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean, + } +) +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref) + +LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id( + LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA +) + + +@automation.register_action( + "light.effect.next", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_next_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, True) + + +@automation.register_action( + "light.effect.previous", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_previous_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, False) + + +async def _light_effect_cycle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + forward: bool, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + cycle_template_arg = cg.TemplateArguments(forward, *template_arg) + var = cg.new_Pvariable(action_id, cycle_template_arg, paren) + cg.add(var.set_include_none(config[CONF_INCLUDE_NONE])) + return var + + CONF_RELATIVE_BRIGHTNESS = "relative_brightness" LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 534dcd2194..c7385cbee3 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -39,6 +39,7 @@ 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) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py index 579e92c62a..aab9072cc8 100644 --- a/tests/component_tests/light/test_effect_validation.py +++ b/tests/component_tests/light/test_effect_validation.py @@ -9,13 +9,17 @@ import pytest from esphome import config_validation as cv from esphome.components.light import ( + EffectCycleRef, EffectRef, _final_validate, _get_data, available_effects_str, find_effect_index, ) -from esphome.components.light.automation import _record_effect_ref +from esphome.components.light.automation import ( + _record_effect_cycle_ref, + _record_effect_ref, +) from esphome.config import Config, path_context from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME from esphome.core import ID, Lambda @@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None: fv.full_config.reset(token) +# --- _final_validate: EffectCycleRef --- + + +def _setup_cycle_final_validate( + cycle_refs: list[EffectCycleRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests.""" + data = _get_data() + data.effect_cycle_refs = cycle_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_cycle_accepts_light_with_effects() -> None: + """Cycle ref against a light with effects should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_without_effects_key() -> None: + """Cycle ref against a light with no CONF_EFFECTS key should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_rejects_light_with_empty_effects() -> None: + """Cycle ref against a light with empty effects list should raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_cycle_unknown_light_id_skipped() -> None: + """Cycle refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_cycle_refs = [ + EffectCycleRef( + light_id=ID("nonexistent", is_declaration=True), + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_cycle_refs() -> None: + """Cycle refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_cycle_final_validate( + cycle_refs=[ + EffectCycleRef(light_id=light_id, component_path=["esphome"]), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_cycle_refs == [] + finally: + fv.full_config.reset(token) + + # --- _record_effect_ref --- @@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None: config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} _record_effect_ref(config) assert _get_data().effect_refs == [] + + +# --- _record_effect_cycle_ref --- + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_cycle_ref() -> None: + """Cycle-action config should be recorded with light_id and path.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id} + result = _record_effect_cycle_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_cycle_refs) == 1 + assert data.effect_cycle_refs[0].light_id is light_id + assert data.effect_cycle_refs[0].component_path == ["esphome"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 044a8144fa..cd9b27768e 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -103,6 +103,16 @@ esphome: - light.turn_on: id: test_monochromatic_light effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' + # Cycle through configured effects (skip "None") + - light.effect.next: test_monochromatic_light + - light.effect.previous: test_monochromatic_light + # Cycle through effects including "None" + - light.effect.next: + id: test_monochromatic_light + include_none: true + - light.effect.previous: + id: test_monochromatic_light + include_none: true - light.dim_relative: id: test_monochromatic_light relative_brightness: 5% From 0b5e7ae8fa052d6e193f730e0024bcd6a54a5fae Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 06:43:08 -0400 Subject: [PATCH 086/282] [sendspin] Bump sendspin-cpp to v0.6.1 (#16553) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 36f13f7d07..b670bd3c4d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 44c63e46cd..6bc166ff44 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -100,6 +100,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.6.0 + version: 0.6.1 lvgl/lvgl: version: 9.5.0 From ac530c33b0c41fbbb923e0945ba6067ec30a5ca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:14 -0500 Subject: [PATCH 087/282] Bump actions/stale from 10.2.0 to 10.3.0 (#16544) 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 2e57093bbb..7003f6c482 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From 99de741f99eff9690ba1e4e5b827c2afaf304194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:08:29 -0500 Subject: [PATCH 088/282] Bump docker/build-push-action from 7.1.0 to 7.2.0 in /.github/actions/build-image (#16545) 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 52d72544d3..2081264b91 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -47,7 +47,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -73,7 +73,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 680c9fc9c0bc651bdf1dbd7890310e96742964e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:49:03 -0500 Subject: [PATCH 089/282] [dashboard] Fix flaky test_websocket_refresh_command on Windows CI (#16565) --- tests/dashboard/test_web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 626aea0216..0ee841e68c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command( ) -> None: """Test WebSocket refresh command triggers dashboard update.""" with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - mock_subscriber.request_refresh = Mock() + # Signal an asyncio.Event when request_refresh is invoked so the + # test can deterministically wait for the server-side handler to run + # instead of relying on a fixed sleep (flaky on Windows CI under load). + called = asyncio.Event() + mock_subscriber.request_refresh = Mock(side_effect=called.set) # Send refresh command await websocket_client.write_message(json.dumps({"event": "refresh"})) - # Give it a moment to process - await asyncio.sleep(0.01) + # Wait for the server to process the message and invoke request_refresh + async with asyncio.timeout(5): + await called.wait() # Verify request_refresh was called mock_subscriber.request_refresh.assert_called_once() From 94b10981e1c78e1a82690edd4ac05a37fa318981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 22 May 2026 22:09:32 +0300 Subject: [PATCH 090/282] [libretiny] Fix LN882H IRAM_ATTR injection point in patch_linker.py (#16570) --- esphome/components/libretiny/patch_linker.py.script | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..3a8a4787ed 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,7 +13,9 @@ import subprocess # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +29,11 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") def _detect(env): @@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content From 64e32ebe046d8e145ddf2fc7f32f9b6d6844cc0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:28 -0500 Subject: [PATCH 091/282] [esp8266] Use os_timer-based esp_delay() in delay() (#16563) --- esphome/components/esp8266/hal.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { From 7182b1a8ae5a727c76f7ad5aebfb4dfa69342e4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:30:43 -0500 Subject: [PATCH 092/282] [uart] Wake main loop on ESP8266 software serial RX (#16562) --- esphome/components/uart/__init__.py | 9 +++++---- .../components/uart/uart_component_esp8266.cpp | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7075228743..4ea32e26a3 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional UART features.""" - if CORE.is_esp32 and CORE.has_networking: - # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer - # registration) — enable by default to reduce RX buffer overflow risk - # by waking the main loop immediately when data arrives. + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. cg.add_define("USE_UART_WAKE_LOOP_ON_RX") diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0ea7930760..fc1509f737 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -4,6 +4,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() { if (this->hw_serial_ != nullptr) { ESP_LOGCONFIG(TAG, " Using hardware serial interface."); } else { - ESP_LOGCONFIG(TAG, " Using software serial"); + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); } this->check_logger_conflict(); } @@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif } void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { From c3bef24389d9cf57598fee58efb58d550730c973 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 22 May 2026 15:43:50 -0400 Subject: [PATCH 093/282] [i2s_audio] Reset dout GPIO when stopping speaker driver (#16573) --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 680ca069c0..691f68e912 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include "esphome/components/audio/audio.h" @@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { i2s_channel_disable(this->tx_handle_); i2s_del_channel(this->tx_handle_); this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } this->parent_->unlock(); } From 4a78c8d45a4a4cc2152e96d994528cdbdbb0beb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:55:49 -0500 Subject: [PATCH 094/282] Bump pytest-codspeed from 5.0.2 to 5.0.3 (#16575) 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 dbdea1d935..102a9cae6e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.2 +pytest-codspeed==5.0.3 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From f85fdb475ad46673d3de9ee01a8a0da3b93183ca Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 07:37:51 +0930 Subject: [PATCH 095/282] [homeassistant] Reduce log spam for sensors (#16555) --- .../components/homeassistant/sensor/homeassistant_sensor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 112795a4ff..b79a56953a 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -17,9 +17,9 @@ void HomeassistantSensor::setup() { } if (this->attribute_ != nullptr) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); + ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); + ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); From a58b4edb6ac12204274d021652f6bbffd82a37a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:06 -0500 Subject: [PATCH 096/282] [ci] Gate unconditional CI jobs on a single determine-jobs output instead of a path filter (#16580) --- .github/workflows/ci.yml | 17 +++--- script/determine-jobs.py | 80 ++++++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b03aec85..53516db913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,6 @@ on: branches: [dev, beta, release] pull_request: - paths: - - "**" - - "!.github/workflows/*.yml" - - "!.github/actions/build-image/*" - - ".github/workflows/ci.yml" - - "!.yamllint" - - "!.github/dependabot.yml" - - "!docker/**" merge_group: permissions: @@ -101,6 +93,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -223,6 +217,8 @@ jobs: runs-on: ${{ matrix.os }} needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -261,6 +257,7 @@ jobs: needs: - common outputs: + core-ci: ${{ steps.determine.outputs.core-ci }} integration-tests: ${{ steps.determine.outputs.integration-tests }} integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} @@ -314,6 +311,7 @@ jobs: echo "$output" | jq # Extract individual fields + echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT @@ -969,7 +967,8 @@ jobs: runs-on: ubuntu-latest needs: - common - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') + - determine-jobs + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 3259fb5836..ef2175eb79 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on what files have changed. It outputs JSON with the following structure: { + "core_ci": true/false, "integration_tests": true/false, "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "clang_tidy": true/false, @@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure: } The CI workflow uses this information to: +- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci; + false when a pull_request only touches CI-irrelevant meta paths (other workflow + files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**) + so workflow-only PRs satisfy the required CI Status check without running the + unconditional jobs. Always true on non-pull_request events and under --force-all. - Skip or run integration tests - Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-format @@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool: return any(get_component_from_path(f) in benchmarked_components for f in files) +# Files / path patterns whose changes alone don't warrant running the +# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`). +# Single source of truth for what we treat as "CI-irrelevant" on +# pull_request events; ci.yml used to encode this in its own +# `pull_request.paths` filter, but that hid the required `CI Status` +# check on PRs that only touched these files (dependabot Action bumps, +# dependabot.yml edits, docker/ changes, etc.) and forced admin +# force-merges. +# +# ci.yml itself is deliberately *not* ignored — editing the CI workflow +# must still run CI. Workflows that have their own dedicated triggers +# (codeql.yml, ci-docker.yml, ...) are matched via the +# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly. +CI_IRRELEVANT_EXACT_FILES = frozenset( + { + ".yamllint", + ".github/dependabot.yml", + } +) + + +def _is_ci_irrelevant_path(path: str) -> bool: + """Whether a single changed path is irrelevant to the unconditional CI jobs.""" + if path in CI_IRRELEVANT_EXACT_FILES: + return True + # docker/** — all descendants + if path.startswith("docker/"): + return True + # .github/workflows/*.yml — top-level workflow files other than ci.yml + # (ci.yml itself must still trigger full CI when edited). + if path.startswith(".github/workflows/") and path.endswith(".yml"): + if path == ".github/workflows/ci.yml": + return False + if "/" not in path[len(".github/workflows/") :]: + return True + # .github/actions/build-image/* — direct children only, matches the + # single-star glob the workflow used to encode. + if path.startswith(".github/actions/build-image/"): + rest = path[len(".github/actions/build-image/") :] + if rest and "/" not in rest: + return True + return False + + +def should_run_core_ci(branch: str | None = None) -> bool: + """Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run. + + Returns False only when every changed file is in the CI-irrelevant set + above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we + never accidentally skip CI when the diff probe fails. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the unconditional CI jobs should run, False otherwise. + """ + files = changed_files(branch) + if not files: + return True + return any(not _is_ci_irrelevant_path(f) for f in files) + + def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: """Check if a changed file ends with any of the specified extensions.""" return any(file.endswith(extensions) for file in changed_files(branch)) @@ -1075,6 +1144,16 @@ def main() -> None: args = parser.parse_args() # Determine what should run + # core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest, + # pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release + # and merge_group) always run them so behavior like venv-cache saves on + # push to dev is preserved. + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + run_core_ci = ( + True + if args.force_all or event_name != "pull_request" + else should_run_core_ci(args.branch) + ) if args.force_all: integration_run_all, integration_test_files = True, [] run_clang_tidy = True @@ -1255,6 +1334,7 @@ def main() -> None: component_test_batches = [] output: dict[str, Any] = { + "core_ci": run_core_ci, "integration_tests": run_integration, "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 202ae9030f..7bb9fe2543 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("path", "expected_result"), + [ + # Exact-file matches in the CI-irrelevant set. + (".yamllint", True), + (".github/dependabot.yml", True), + # Other top-level workflow files are irrelevant; ci.yml itself is not. + (".github/workflows/codeql.yml", True), + (".github/workflows/release.yml", True), + (".github/workflows/ci.yml", False), + # Nested files under workflows/ are not matched by the single-star glob. + (".github/workflows/matchers/gcc.json", False), + # build-image action: direct children only (single-star glob). + (".github/actions/build-image/action.yml", True), + (".github/actions/build-image/nested/file.yml", False), + # Other actions are CI-relevant. + (".github/actions/restore-python/action.yml", False), + # docker/** covers everything under docker/. + ("docker/Dockerfile", True), + ("docker/scripts/run.sh", True), + # Regular source files are CI-relevant. + ("esphome/__main__.py", False), + ("esphome/components/wifi/wifi_component.cpp", False), + ("README.md", False), + ("tests/script/test_determine_jobs.py", False), + ], +) +def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None: + """Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter.""" + assert determine_jobs._is_ci_irrelevant_path(path) == expected_result + + +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # Empty diffs default to True — don't accidentally skip CI on a + # broken probe. + ([], True), + # Any CI-relevant file flips the result to True. + (["esphome/__main__.py"], True), + (["esphome/components/wifi/wifi_component.cpp"], True), + (["README.md"], True), + # All-irrelevant diffs return False. + ([".github/workflows/codeql.yml"], False), + ( + [".github/workflows/codeql.yml", ".github/workflows/release.yml"], + False, + ), + ([".yamllint"], False), + ([".github/dependabot.yml"], False), + (["docker/Dockerfile"], False), + ( + [ + ".github/workflows/codeql.yml", + ".github/dependabot.yml", + "docker/Dockerfile", + ], + False, + ), + # Mixed diffs always trigger CI. + ( + [".github/workflows/codeql.yml", "esphome/__main__.py"], + True, + ), + # ci.yml itself is treated as CI-relevant. + ([".github/workflows/ci.yml"], True), + ], +) +def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None: + """Test should_run_core_ci function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + assert determine_jobs.should_run_core_ci() == expected_result + + +def test_should_run_core_ci_with_branch() -> None: + """Test should_run_core_ci passes the branch through to changed_files.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_core_ci("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ From 71550bb3bee1980f7edaa71e68445aa05a007984 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:39:25 -0500 Subject: [PATCH 097/282] [lvgl] Memoize and lazily build container_schema (#16567) --- esphome/components/lvgl/schemas.py | 42 ++++++--- .../lvgl/test_container_schema_cache.py | 87 +++++++++++++++++++ 2 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/lvgl/test_container_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 553e0f7398..58ef88d6a8 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation @@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema): return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) -def container_schema(widget_type: WidgetType, extras=None): +# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both +# alive so id() can't be recycled. +_CONTAINER_SCHEMA_CACHE: dict[ + tuple[int, int], tuple[Any, Any, Callable[[Any], Any]] +] = {} + + +def container_schema( + widget_type: WidgetType, extras: Any = None +) -> Callable[[Any], Any]: """ Create a schema for a container widget of a given type. All obj properties are available, plus the extras passed in, plus any defined for the specific widget being specified. @@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None): :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - # Delayed evaluation for recursion + cache_key = (id(widget_type), id(extras)) + cached = _CONTAINER_SCHEMA_CACHE.get(cache_key) + if cached is not None: + cached_widget_type, cached_extras, cached_validator = cached + if cached_widget_type is widget_type and cached_extras is extras: + return cached_validator - schema = schema.extend(widget_type.schema) + cached_schema: cv.Schema | None = None - def validator(value): + def get_schema() -> cv.Schema: + nonlocal cached_schema + if cached_schema is None: + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + cached_schema = schema.extend(widget_type.schema) + return cached_schema + + def validator(value: Any) -> Any: value = value or {} - return append_layout_schema(schema, value)(value) + return append_layout_schema(get_schema(), value)(value) + _CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator) return validator diff --git a/tests/component_tests/lvgl/test_container_schema_cache.py b/tests/component_tests/lvgl/test_container_schema_cache.py new file mode 100644 index 0000000000..39e623d720 --- /dev/null +++ b/tests/component_tests/lvgl/test_container_schema_cache.py @@ -0,0 +1,87 @@ +"""Tests for container_schema() memoization and lazy build.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from esphome import config_validation as cv +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema + + +@pytest.fixture(autouse=True) +def _clear_container_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_args_return_same_validator() -> None: + wt = _widget_type("obj") + assert container_schema(wt) is container_schema(wt) + + +def test_extras_none_vs_truthy_get_different_validators() -> None: + wt = _widget_type("obj") + no_extras = container_schema(wt) + extras = {cv.Optional("custom_extra"): cv.string} + assert no_extras is not container_schema(wt, extras) + + +def test_different_widget_types_get_different_validators() -> None: + assert container_schema(_widget_type("obj")) is not container_schema( + _widget_type("label") + ) + + +def test_schema_build_is_deferred_until_first_validation() -> None: + wt = _widget_type("obj") + with patch.object( + lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema + ) as obj_schema_mock: + validator = container_schema(wt) + assert obj_schema_mock.call_count == 0 + validator({}) + assert obj_schema_mock.call_count == 1 + validator({}) + assert obj_schema_mock.call_count == 1 + + +def test_cached_validator_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached = container_schema(wt) + cached_result = cached({}) + lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear() + reference = container_schema(wt) + assert cached is not reference + assert cached_result == reference({}) + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_extras = {cv.Optional("a"): cv.int_} + validator_a = container_schema(wt, real_extras) + + cache_key = (id(wt), id(real_extras)) + cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] + sentinel = {cv.Optional("a"): cv.int_} + lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = ( + cached_entry[0], + sentinel, + cached_entry[2], + ) + + assert container_schema(wt, real_extras) is not validator_a From 55f4e5cb7553c4a77e65e0d987c2fe29817dee67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:20 -0500 Subject: [PATCH 098/282] Bump the docker-actions group across 1 directory with 2 updates (#16578) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 3fd17888c7..89fbec5420 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -48,7 +48,7 @@ jobs: with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Set TAG run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9799f882db..344bd416c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} From 9930b3c216cefdd69599a53da337d42652ffbdce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:18:31 -0500 Subject: [PATCH 099/282] Bump github/codeql-action from 4.35.5 to 4.36.0 (#16579) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fbef0f5157..dfc0e08bfa 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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" From 2b422cbd991d26125986a3f5caa40d2edda60198 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 19:20:39 -0500 Subject: [PATCH 100/282] [lvgl] Build widget update action schemas lazily (#16569) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/widgets/__init__.py | 36 +++++++++++-- .../lvgl/test_update_action_lazy.py | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_update_action_lazy.py diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index ab1c61ff88..400f7c709b 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import sys +from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -15,6 +17,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( @@ -73,6 +76,34 @@ from ..types import ( EVENT_LAMB = "event_lamb__" +def _build_update_schema(widget_type: "WidgetType") -> Schema: + # Local import: ..schemas imports WidgetType from this module. + from ..schemas import base_update_schema + + return base_update_schema(widget_type, widget_type.parts).extend( + widget_type.modify_schema + ) + + +def _update_action_schema( + widget_type: "WidgetType", +) -> Schema | Callable[[Any], Any]: + # Eager when extracting so build_language_schema.py sees the mapping; + # lazy otherwise to skip ~200 ms of import-time voluptuous work. + if EnableSchemaExtraction: + return _build_update_schema(widget_type) + + cached: Schema | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = _build_update_schema(widget_type) + return cached(value) + + return validator + + class WidgetType: """ Describes a type of Widget, e.g. "bar" or "line" @@ -113,18 +144,17 @@ class WidgetType: # Local import to avoid circular import from ..automation import update_to_code - from ..schemas import WIDGET_TYPES, base_update_schema + from ..schemas import WIDGET_TYPES if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self - # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, - base_update_schema(self, self.parts).extend(self.modify_schema), + _update_action_schema(self), synchronous=True, )(update_to_code) diff --git a/tests/component_tests/lvgl/test_update_action_lazy.py b/tests/component_tests/lvgl/test_update_action_lazy.py new file mode 100644 index 0000000000..7fcdc149cf --- /dev/null +++ b/tests/component_tests/lvgl/test_update_action_lazy.py @@ -0,0 +1,53 @@ +"""Tests for lvgl..update lazy schema build.""" + +from __future__ import annotations + +from unittest.mock import patch + +from esphome.automation import ACTION_REGISTRY +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl.schemas import WIDGET_TYPES +from esphome.components.lvgl.widgets import _update_action_schema +from esphome.config_validation import Schema + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_registry_entry_uses_lazy_validator() -> None: + entry = ACTION_REGISTRY["lvgl.label.update"] + assert callable(entry.raw_schema) + assert not isinstance(entry.raw_schema, Schema) + + +def test_lazy_validator_defers_build_until_first_call() -> None: + wt = _widget_type("label") + with patch( + "esphome.components.lvgl.widgets._build_update_schema", + wraps=lambda w: Schema({}), + ) as build_mock: + validator = _update_action_schema(wt) + assert build_mock.call_count == 0 + validator({}) + assert build_mock.call_count == 1 + validator({}) + assert build_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + result = _update_action_schema(wt) + assert isinstance(result, Schema) + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + wt = _widget_type("label") + with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True): + eager = _update_action_schema(wt) + lazy = _update_action_schema(wt) + sample = {"id": "label_id"} + assert lazy(sample) == eager(sample) From b0dc688c148b5bc1c5c22459c5271b60952dfd6b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 22 May 2026 20:30:25 -0400 Subject: [PATCH 101/282] [esp32] Demote IDF #warning deprecations from error under ESP-IDF toolchain (#16584) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 274cc6fdb3..24312d64ad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1821,6 +1821,7 @@ async def to_code(config): cg.add_build_flag("-Wno-error=overloaded-virtual") cg.add_build_flag("-Wno-error=reorder") cg.add_build_flag("-Wno-error=volatile") + cg.add_build_flag("-Wno-error=cpp") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From be99553fd4aad7afdbb5d9a49037eb30656008d0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 23 May 2026 13:56:53 +0930 Subject: [PATCH 102/282] [ci] Fix flash memory overflow on tests (#16587) --- tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml | 2 ++ .../build_components_base.esp32-c6-idf.yaml | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml index 6c27bd35d0..df5b0123b5 100644 --- a/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml +++ b/tests/components/bluetooth_proxy/test.esp32-c6-idf.yaml @@ -1,6 +1,8 @@ <<: !include common.yaml esp32_ble_tracker: + +esp32_ble: max_connections: 9 bluetooth_proxy: diff --git a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml index 9dbc465ca2..4105481dc5 100644 --- a/tests/test_build_components/build_components_base.esp32-c6-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-c6-idf.yaml @@ -4,6 +4,7 @@ esphome: esp32: board: esp32-c6-devkitc-1 + flash_size: 8MB framework: type: esp-idf From 188ff7ebfd70bc1f3fb417af5dbc41a486bcf99b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2026 14:30:12 -0500 Subject: [PATCH 103/282] [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) --- .../bluetooth_proxy/bluetooth_connection.cpp | 26 ++++++++++++------- .../bluetooth_proxy/bluetooth_connection.h | 2 ++ .../esp32_ble_client/ble_client_base.cpp | 2 ++ .../esp32_ble_client/ble_client_base.h | 10 +++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 21573f0184..7ba9e61e19 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,12 +135,26 @@ void BluetoothConnection::loop() { // - For V3_WITH_CACHE: Services are never sent, disable after INIT state // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the + // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. + if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && + (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { this->disable_loop(); } } +void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { + // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the + // base class. Free the proxy slot, notify the API client, and reset send_service_. + // address_ may already be 0 if reset_connection_ ran earlier on this teardown. + if (this->address_ == 0) { + return; + } + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); + this->reset_connection_(reason); +} + void BluetoothConnection::reset_connection_(esp_err_t reason) { // Send disconnection notification this->proxy_->send_device_connection(this->address_, false, 0, reason); @@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.reason); - break; - } case ESP_GATTC_OPEN_EVT: { if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->reset_connection_(param->open.status); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index b50ea2d6a2..e5600f6af4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + void on_disconnect_complete(esp_err_t reason) override; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..3fb9632e9a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -72,6 +72,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..0291a4b993 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); 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. + /// 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. + 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_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); From f61610362182d077af8f7e0e80ebca3fc3a77d87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:25 -0400 Subject: [PATCH 104/282] [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) --- esphome/components/esp32/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 24312d64ad..1864c3b544 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,12 +1816,9 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") - cg.add_build_flag("-Wno-error=cpp") + # Undo IDF's blanket -Werror so third-party libraries and user + # lambdas don't need a -Wno-error= entry per warning class. + cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From 58931f2610f65a5ca6c8c6204ed5253b18ebf378 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:37:59 -0400 Subject: [PATCH 105/282] [audio] Add `clear_buffered_data` method to RingBufferAudioSource (#16594) --- .../components/audio/audio_transfer_buffer.cpp | 16 ++++++++++++++++ esphome/components/audio/audio_transfer_buffer.h | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index d9ce8060e2..a611549e58 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) { } } +void RingBufferAudioSource::clear_buffered_data() { + // Release the held item before reset() so the source no longer references memory the reset will reclaim. + if (this->acquired_item_ != nullptr) { + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; + } + this->current_data_ = nullptr; + this->current_available_ = 0; + this->queued_data_ = nullptr; + this->queued_length_ = 0; + this->item_trailing_ptr_ = nullptr; + this->item_trailing_length_ = 0; + this->splice_length_ = 0; + this->ring_buffer_->reset(); +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index b713326141..074684f068 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// exposure stays in place and fill() returns 0 until it is fully consumed. size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight + /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. + void clear_buffered_data(); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data From 74001ccf05646c1a1c0dfe80df0724db822613e4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:39:20 -0400 Subject: [PATCH 106/282] [wifi] Wake main loop when requesting high performance mode (#16598) --- esphome/components/wifi/wifi_component.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index edfb93bba2..72832d7ac8 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2193,7 +2193,15 @@ bool WiFiComponent::request_high_performance() { } // Give the semaphore (non-blocking). This increments the count. - return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; + + // Wake the main loop so the switch to high-performance mode is applied on the + // next tick instead of waiting up to loop_interval. + if (success) { + App.wake_loop_threadsafe(); + } + + return success; } bool WiFiComponent::release_high_performance() { From 5cb145a8c3869e568adbb4628d99d10181e0b473 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:53:53 -0400 Subject: [PATCH 107/282] [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) --- .../ethernet/ethernet_component_esp32.cpp | 5 + .../components/ethernet/w5500_custom_spi.cpp | 118 ++++++++++++++++++ .../components/ethernet/w5500_custom_spi.h | 35 ++++++ 3 files changed, 158 insertions(+) create mode 100644 esphome/components/ethernet/w5500_custom_spi.cpp create mode 100644 esphome/components/ethernet/w5500_custom_spi.h diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d4585bf100..46e2bb4ec1 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "w5500_custom_spi.h" #include #include @@ -207,6 +208,10 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif + // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait + // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which + // runs the driver's init(). + install_w5500_async_spi(w5500_config); #elif defined(USE_ETHERNET_DM9051) dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp new file mode 100644 index 0000000000..ed4f149738 --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -0,0 +1,118 @@ +#include "w5500_custom_spi.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +#include +#include +#include +#include + +namespace esphome::ethernet { + +namespace { + +// Per-device context returned by init() and handed back to read/write/deinit. +struct W5500CustomSpiContext { + spi_device_handle_t handle; + SemaphoreHandle_t lock; +}; + +// 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. +constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64; +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; + } + // 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; + return nullptr; + } + ctx->lock = xSemaphoreCreateMutex(); + if (ctx->lock == nullptr) { + spi_bus_remove_device(ctx->handle); + delete ctx; + return nullptr; + } + return ctx; +} + +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; + return ESP_OK; +} + +// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size. +// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register +// accesses stay on the cheaper polling path. Used by both read and write. +esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) { + if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + esp_err_t ret; + if (len > W5500_SPI_BULK_THRESHOLD) { + ret = spi_device_transmit(ctx->handle, trans); + } else { + ret = spi_device_polling_transmit(ctx->handle, trans); + } + xSemaphoreGive(ctx->lock); + return ret; +} + +esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.tx_buffer = data; + return w5500_custom_spi_transfer(ctx, &trans, len); +} + +esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary + // overwrites of adjacent registers (same guard the stock driver uses). + const bool use_rxdata = len <= 4; + trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.rx_buffer = data; + esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len); + if (use_rxdata && (ret == ESP_OK)) { + memcpy(data, trans.rx_data, len); + } + return ret; +} + +} // namespace + +void install_w5500_async_spi(eth_w5500_config_t &config) { + // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and + // spi_devcfg back out of it. The self-reference is valid because both the config and the + // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). + config.custom_spi_driver.config = &config; + config.custom_spi_driver.init = w5500_custom_spi_init; + config.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.custom_spi_driver.read = w5500_custom_spi_read; + config.custom_spi_driver.write = w5500_custom_spi_write; +} + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h new file mode 100644 index 0000000000..8756a149af --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t +// is no longer reachable through esp_eth.h and needs the explicit header. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else +#include +#endif + +namespace esphome::ethernet { + +// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path. +// +// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which +// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame, +// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX +// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers +// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps +// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait +// is cheaper than an interrupt round-trip. +// +// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back +// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay +// alive until esp_eth_mac_new_w5500() returns. +void install_w5500_async_spi(eth_w5500_config_t &config); + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 From c951881eea1a4066eef6d5ab1d872ccac0362bb4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 23 May 2026 23:05:18 -0500 Subject: [PATCH 108/282] [api] Fix uint32_t/int32_t format strings for stricter GCC toolchain (#16603) --- esphome/components/api/api_server.cpp | 7 ++++--- tests/components/api/common-base.yaml | 28 +++++++++++++-------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e612..031fa342c1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -1,6 +1,7 @@ #include "api_server.h" #ifdef USE_API #include +#include #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" @@ -677,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn // Schedule automatic cleanup after timeout (client will have given up by then) // Uses numeric ID overload to avoid heap allocation from str_sprintf this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() { - ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id); this->unregister_active_action_call(action_call_id); }); @@ -721,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message, @@ -733,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 504c52a57b..ca86445777 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -120,12 +120,12 @@ api: lambda: 'return condition;' then: - logger.log: - format: "Condition true, value: %d" - args: ['value'] + format: "Condition true, value: %ld" + args: ['(long) value'] else: - logger.log: - format: "Condition false, value: %d" - args: ['value'] + format: "Condition false, value: %ld" + args: ['(long) value'] - logger.log: "After if/else" # Test nested IfAction (multiple ContinuationAction instances) - action: test_nested_if @@ -171,8 +171,8 @@ api: count: !lambda 'return count;' then: - logger.log: - format: "Repeat iteration: %d" - args: ['iteration'] + format: "Repeat iteration: %lu" + args: ['(unsigned long) iteration'] - logger.log: "After repeat" # Test combined continuations (if + while + repeat) - action: test_combined_continuations @@ -193,8 +193,8 @@ api: lambda: 'return id(api_continuation_test_counter) > 0;' then: - logger.log: - format: "Combined: repeat=%d, while=%d" - args: ['iteration', 'id(api_continuation_test_counter)'] + format: "Combined: repeat=%lu, while=%d" + args: ['(unsigned long) iteration', 'id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)--;' else: - logger.log: "Skipped loops" @@ -208,8 +208,8 @@ api: - api.respond: success: true - logger.log: - format: "Status response sent (call_id=%d)" - args: [call_id] + format: "Status response sent (call_id=%lu)" + args: ['(unsigned long) call_id'] - action: test_respond_status_error variables: @@ -229,8 +229,8 @@ api: value: float then: - logger.log: - format: "Optional response (call_id=%d, return_response=%d)" - args: [call_id, return_response] + format: "Optional response (call_id=%lu, return_response=%lu)" + args: ['(unsigned long) call_id', '(unsigned long) return_response'] - api.respond: data: !lambda |- root["sensor"] = sensor_name; @@ -264,8 +264,8 @@ api: input: string then: - logger.log: - format: "Only response (call_id=%d)" - args: [call_id] + format: "Only response (call_id=%lu)" + args: ['(unsigned long) call_id'] - api.respond: data: !lambda |- root["input"] = input; From 5f860ff5bdabfb574554e617ed5379dbd847fd04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 24 May 2026 00:19:07 -0400 Subject: [PATCH 109/282] [esp32] Disable IDF's COMPILER_DISABLE_DEFAULT_ERRORS so -Wno-error actually undoes -Werror (#16604) --- esphome/components/esp32/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1864c3b544..a06ae89c3e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1816,8 +1816,11 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - # Undo IDF's blanket -Werror so third-party libraries and user - # lambdas don't need a -Wno-error= entry per warning class. + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) cg.add_build_flag("-Wno-error") # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates cg.add_build_flag("-Wno-missing-field-initializers") From a37f27ee7f2d7ed74f666aa1b53d394b89b8d36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Sun, 24 May 2026 07:27:31 +0200 Subject: [PATCH 110/282] [espnow, ethernet, network, openthread, wifi] centralize network initialization for ESP32 (#14012) Co-authored-by: kbx81 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 2 +- .../components/espnow/espnow_component.cpp | 8 ----- .../ethernet/ethernet_component_esp32.cpp | 6 +--- esphome/components/network/__init__.py | 17 +++++++++- .../components/network/network_component.cpp | 33 +++++++++++++++++++ .../components/network/network_component.h | 14 ++++++++ .../components/openthread/openthread_esp.cpp | 3 +- esphome/components/wifi/wifi_component.cpp | 3 -- .../wifi/wifi_component_esp_idf.cpp | 14 ++------ 9 files changed, 69 insertions(+), 31 deletions(-) create mode 100644 esphome/components/network/network_component.cpp create mode 100644 esphome/components/network/network_component.h diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 7861c0affa..13f278d3bc 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,7 +17,7 @@ from esphome.core import HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] - +AUTO_LOAD = ["network"] byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91d44394e8..403e6f4944 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { -#ifndef USE_WIFI - // Initialize LwIP stack for wake_loop_threadsafe() socket support - // When WiFi component is present, it handles esp_netif_init() - ESP_ERROR_CHECK(esp_netif_init()); -#endif - if (this->enable_on_boot_) { this->enable_(); } else { @@ -174,8 +168,6 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - esp_event_loop_create_default(); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 46e2bb4ec1..6481c8c1f4 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -164,11 +164,7 @@ void EthernetComponent::setup() { err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); #endif - - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); + // Network interface setup handled by network component esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); this->eth_netif_ = esp_netif_new(&cfg); diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 811e7c875a..2818b8c93e 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -5,8 +5,9 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT +from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] @@ -19,6 +20,7 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" network_ns = cg.esphome_ns.namespace("network") +NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") @@ -107,6 +109,7 @@ def has_high_performance_networking() -> bool: CONFIG_SCHEMA = cv.Schema( { + cv.GenerateID(): cv.declare_id(NetworkComponent), cv.SplitDefault( CONF_ENABLE_IPV6, bk72xx=False, @@ -224,3 +227,15 @@ async def to_code(config): cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") if CORE.is_rp2040: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") + # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it + # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config + # (above) separate from C++ object lifecycle and allows wiring in interface + # pointers via get_variable(). + if CORE.is_esp32: + CORE.add_job(network_component_to_code, config) + + +@coroutine_with_priority(CoroPriority.NETWORK_SERVICES) +async def network_component_to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp new file mode 100644 index 0000000000..40cf64906c --- /dev/null +++ b/esphome/components/network/network_component.cpp @@ -0,0 +1,33 @@ +#include "network_component.h" + +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_netif.h" +#include "esp_event.h" +namespace esphome::network { + +static const char *const TAG = "network"; + +void NetworkComponent::setup() { + // Initialize ESP-IDF network interfaces and ensure the default event loop exists + esp_err_t err; + err = esp_netif_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } + err = esp_event_loop_create_default(); + // ESP_ERR_INVALID_STATE is returned if the default loop already exists, + // which is fine since we just want to make sure it exists + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err)); + this->mark_failed(); + return; + } +} + +} // namespace esphome::network +#endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h new file mode 100644 index 0000000000..dde15940e4 --- /dev/null +++ b/esphome/components/network/network_component.h @@ -0,0 +1,14 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_NETWORK) && defined(USE_ESP32) +#include "esphome/core/component.h" + +namespace esphome::network { +class NetworkComponent : public Component { + public: + void setup() override; + // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } +}; +} // namespace esphome::network +#endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 27712bd86a..787f2f5de8 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -35,9 +35,8 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; + // Network interface setup handled by network component ESP_ERROR_CHECK(nvs_flash_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); - ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); xTaskCreate( diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 72832d7ac8..fdbd70bc61 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -634,9 +634,6 @@ void WiFiComponent::setup() { if (this->enable_on_boot_) { this->start(); } else { -#ifdef USE_ESP32 - esp_netif_init(); -#endif this->state_ = WIFI_COMPONENT_STATE_DISABLED; } } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4f39a3a4b1..11b39b5000 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -145,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() { get_mac_address_raw(mac); set_mac_address(mac); } - esp_err_t err = esp_netif_init(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err)); - return; - } + // Network interface setup handled by network component s_wifi_event_group = xEventGroupCreate(); if (s_wifi_event_group == nullptr) { ESP_LOGE(TAG, "xEventGroupCreate failed"); return; } - err = esp_event_loop_create_default(); - if (err != ERR_OK) { - ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err)); - return; - } esp_event_handler_instance_t instance_wifi_id, instance_ip_id; - err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); + esp_err_t err = + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; From 750d52741a8c5136e855c4f1ad5992f64d973630 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 08:36:53 -0400 Subject: [PATCH 111/282] [voice_assistant] Use RingBufferAudioSource (#16597) --- .../components/voice_assistant/__init__.py | 2 +- .../voice_assistant/voice_assistant.cpp | 129 ++++++++---------- .../voice_assistant/voice_assistant.h | 13 +- 3 files changed, 63 insertions(+), 81 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 958d1cbf91..f41adfd8de 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_SPEAKER, ) -AUTO_LOAD = ["ring_buffer", "socket"] +AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 286e6645d2..af1b98da02 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -30,7 +30,7 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { this->mic_source_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_; + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -39,7 +39,7 @@ void VoiceAssistant::setup() { // Second microphone channel if (this->mic_source2_ != nullptr) { this->mic_source2_->add_data_callback([this](const std::vector &data) { - std::shared_ptr temp_ring_buffer = this->ring_buffer2_; + std::shared_ptr temp_ring_buffer = this->ring_buffer2_.lock(); if (temp_ring_buffer != nullptr) { temp_ring_buffer->write((void *) data.data(), data.size()); } @@ -125,62 +125,47 @@ bool VoiceAssistant::allocate_buffers_() { } #endif - if (this->ring_buffer_ == nullptr) { - this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer_ == nullptr) { + if (this->audio_source_ == nullptr) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; } - } - - if (this->send_buffer_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (send_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate send buffer"); + // Zero-copy source that reads directly from the ring buffer; frame-aligned to never split an int16 sample. + this->audio_source_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate audio source"); return false; } + this->ring_buffer_ = temp_ring_buffer; } // Second microphone channel - if (this->mic_source2_ != nullptr) { - if (this->ring_buffer2_ == nullptr) { - this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); - if (this->ring_buffer2_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate second ring buffer"); - return false; - } + if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + if (temp_ring_buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate second ring buffer"); + return false; } - - if (this->send_buffer2_ == nullptr) { - RAMAllocator send_allocator; - this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE); - if (this->send_buffer2_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate second send buffer"); - return false; - } + this->audio_source2_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t)); + if (this->audio_source2_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate second audio source"); + return false; } + this->ring_buffer2_ = temp_ring_buffer; } return true; } void VoiceAssistant::clear_buffers_() { - if (this->send_buffer_ != nullptr) { - memset(this->send_buffer_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer_ != nullptr) { - this->ring_buffer_->reset(); + if (this->audio_source_ != nullptr) { + this->audio_source_->clear_buffered_data(); } // Second microphone channel - if (this->send_buffer2_ != nullptr) { - memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE); - } - - if (this->ring_buffer2_ != nullptr) { - this->ring_buffer2_->reset(); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); } #ifdef USE_SPEAKER @@ -195,22 +180,11 @@ void VoiceAssistant::clear_buffers_() { } void VoiceAssistant::deallocate_buffers_() { - if (this->send_buffer_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer_, SEND_BUFFER_SIZE); - this->send_buffer_ = nullptr; - } - - this->ring_buffer_.reset(); + // Destroying each source releases its ring buffer; the matching weak_ptr then expires automatically. + this->audio_source_.reset(); // Second microphone channel - if (this->send_buffer2_ != nullptr) { - RAMAllocator send_deallocator; - send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE); - this->send_buffer2_ = nullptr; - } - - this->ring_buffer2_.reset(); + this->audio_source2_.reset(); #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { @@ -316,52 +290,57 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { + // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { // API audio // Both microphone channels are sent, if configured - bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - bool is_available2 = false; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; + size_t available = this->audio_source_->fill(0, false); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } - while (is_available || is_available2) { + while (available > 0 || available2 > 0) { api::VoiceAssistantAudio msg; - if (is_available) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); - msg.data = this->send_buffer_; - msg.data_len = read_bytes; + if (available > 0) { + // Zero-copy: send_message() copies the data out before we consume it + msg.data = this->audio_source_->data(); + msg.data_len = available; } // Second microphone channel - if (is_available2) { - size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0); - msg.data2 = this->send_buffer2_; - msg.data2_len = read_bytes; + if (available2 > 0) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; } this->api_client_->send_message(msg); - is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE; - if (this->mic_source2_) { - is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE; - } else { - is_available2 = false; + + if (available > 0) { + this->audio_source_->consume(available); + } + available = this->audio_source_->fill(0, false); + if (available2 > 0) { + this->audio_source2_->consume(available2); + } + if (this->audio_source2_ != nullptr) { + available2 = this->audio_source2_->fill(0, false); } } } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) { - size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0); + while (this->audio_source_->fill(0, false) > 0) { if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_, - sizeof(this->dest_addr_)); + this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, + (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); + this->audio_source_->consume(this->audio_source_->available()); } } // audio mode break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index c4fa7eb615..f3ea669e15 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -9,6 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/api/api_connection.h" +#include "esphome/components/audio/audio_transfer_buffer.h" #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/microphone/microphone_source.h" @@ -306,8 +307,13 @@ class VoiceAssistant : public Component { std::string wake_word_; - std::shared_ptr ring_buffer_; - std::shared_ptr ring_buffer2_; + // Zero-copy sources that read directly from each microphone channel's ring buffer internal storage. + // Each source owns its ring buffer; the matching ``ring_buffer_``/``ring_buffer2_`` weak_ptr is used by + // the microphone callback (a different thread) to write into it. + std::unique_ptr audio_source_; + std::unique_ptr audio_source2_; + std::weak_ptr ring_buffer_; + std::weak_ptr ring_buffer2_; bool use_wake_word_; uint8_t noise_suppression_level_; @@ -315,9 +321,6 @@ class VoiceAssistant : public Component { float volume_multiplier_; uint32_t conversation_timeout_; - uint8_t *send_buffer_{nullptr}; - uint8_t *send_buffer2_{nullptr}; - bool continuous_{false}; bool silence_detection_; From c17c4478ac17f375c79d561701cdc92a7152ae91 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:32:43 -0400 Subject: [PATCH 112/282] [mixer] Support any bit depth audio (#16524) --- esphome/components/mixer/speaker/__init__.py | 29 +-- .../mixer/speaker/mixer_speaker.cpp | 210 +++--------------- .../components/mixer/speaker/mixer_speaker.h | 58 +---- tests/components/mixer/common.yaml | 4 + 4 files changed, 65 insertions(+), 236 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 59a80d9297..8501843d3f 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), - cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16), } ) -def _set_stream_limits(config): - audio.set_stream_limits( - min_bits_per_sample=16, - max_bits_per_sample=16, - )(config) - - return config - - def _validate_source_speaker(config): fconf = fv.full_config.get() @@ -67,15 +57,25 @@ def _validate_source_speaker(config): output_speaker_id = fconf.get_config_for_path(path) config[CONF_OUTPUT_SPEAKER] = output_speaker_id + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( + "mixer", + audio_device=CONF_OUTPUT_SPEAKER, + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _validate_output_speaker(config): audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), )(config) return config @@ -89,8 +89,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SOURCE_SPEAKERS): cv.All( cv.ensure_list(SOURCE_SPEAKER_SCHEMA), cv.Length(min=2, max=8), - [_set_stream_limits], ), + cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, @@ -100,13 +100,15 @@ CONFIG_SCHEMA = cv.All( ) FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER), + inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), cv.Schema( { cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker], }, extra=cv.ALLOW_EXTRA, ), - inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), + _validate_output_speaker, ) @@ -116,6 +118,7 @@ async def to_code(config): spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) + cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 1a995a6edf..6128dc3767 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -7,8 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include // esp-audio-libs +#include // esp-audio-libs + #include -#include #include namespace esphome::mixer_speaker { @@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX; -static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN; - static const char *const TAG = "speaker_mixer"; -// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB -// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) -// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::array DECIBEL_REDUCTION_TABLE = { - 32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183, - 4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731, - 651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103}; - // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { SOURCE_SPEAKER_COMMAND_START = (1 << 0), @@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(audio_source->mutable_data()); - - duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, - &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, - this->db_change_per_ducking_step_); + 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_); } return bytes_read; } void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) { - if (this->target_ducking_db_reduction_ != decibel_reduction) { - // Start transition from the previous target (which becomes the new current level) - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - - this->target_ducking_db_reduction_ = decibel_reduction; - - // Calculate the number of intermediate dB steps for the transition timing. - // Subtract 1 because the first step is taken immediately after this calculation. - uint8_t total_ducking_steps = 0; - if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) { - // The dB reduction level is increasing (which results in quieter audio) - total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = 1; - } else { - // The dB reduction level is decreasing (which results in louder audio) - total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = -1; - } - if ((duration > 0) && (total_ducking_steps > 0)) { - this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration); - - this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps; - this->ducking_transition_samples_remaining_ = - this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding - - this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_; - } else { - this->ducking_transition_samples_remaining_ = 0; - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - } - } -} - -void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, - int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining, - uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) { - if (*ducking_transition_samples_remaining > 0) { - // Ducking level is still transitioning - - // Takes the ceiling of input_samples_to_duck/samples_per_ducking_step - uint32_t ducking_steps_in_batch = - input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0); - - for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) { - uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step; - - if (samples_left_in_step == 0) { - samples_left_in_step = samples_per_ducking_step; - } - - uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step); - samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining); - - // Ensure we only point to valid index in the Q15 scaling factor table - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck); - - if (samples_left_in_step - samples_to_duck == 0) { - // After scaling the current samples, we are ready to transition to the next step - *current_ducking_db_reduction += db_change_per_ducking_step; - } - - input_buffer += samples_to_duck; - *ducking_transition_samples_remaining -= samples_to_duck; - input_samples_to_duck -= samples_to_duck; - } - } - - if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) { - // Audio is ducked, but its not in the middle of a transition step - - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck); - } + const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; + esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() { void MixerSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Speaker Mixer:\n" - " Number of output channels: %u", - this->output_channels_); + " Number of output channels: %" PRIu8 "\n" + " Output bits per sample: %" PRIu8, + this->output_channels_, this->output_bits_per_sample_); } void MixerSpeaker::setup() { @@ -512,13 +424,8 @@ void MixerSpeaker::loop() { esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { if (!this->audio_stream_info_.has_value()) { - if (stream_info.get_bits_per_sample() != 16) { - // Audio streams that don't have 16 bits per sample are not supported - return ESP_ERR_NOT_SUPPORTED; - } - - this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_, - stream_info.get_sample_rate()); + this->audio_stream_info_ = + audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate()); this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value()); } else { if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) { @@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { return ESP_OK; } -void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_transfer) { - uint8_t input_channels = input_stream_info.get_channels(); - uint8_t output_channels = output_stream_info.get_channels(); - const uint8_t max_input_channel_index = input_channels - 1; - - if (input_channels == output_channels) { - size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer); - memcpy(output_buffer, input_buffer, bytes_to_copy); - - return; - } - - for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index); - output_buffer[output_channels * frame_index + output_channel_index] = - input_buffer[input_channels * frame_index + input_channel_index]; - } - } -} - -void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix) { - const uint8_t primary_channels = primary_stream_info.get_channels(); - const uint8_t secondary_channels = secondary_stream_info.get_channels(); - const uint8_t output_channels = output_stream_info.get_channels(); - - const uint8_t max_primary_channel_index = primary_channels - 1; - const uint8_t max_secondary_channel_index = secondary_channels - 1; - - for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index); - const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index]; - - const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index); - const int32_t primary_sample = - static_cast(primary_buffer[frames_index * primary_channels + primary_channel_index]); - - const int32_t added_sample = secondary_sample + primary_sample; - - output_buffer[frames_index * output_channels + output_channel_index] = - static_cast(clamp(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE)); - } - } -} - // NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created void MixerSpeaker::audio_mixer_task(void *params) { MixerSpeaker *this_mixer = static_cast(params); @@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) { uint32_t frames_to_mix = output_frames_free; + const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value(); + const uint8_t output_bps = output_info.get_bits_per_sample() / 8; + const uint8_t output_channels = output_info.get_channels(); + if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) { // Only one speaker has audio data, just copy samples over @@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (active_stream_info.get_sample_rate() == this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + // Speaker's sample rate matches the output speaker's, convert directly into the output buffer const uint32_t frames_available_in_buffer = active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(audio_sources_with_data[0]->data()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::pcm_convert::copy_frames( + audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(), + static_cast(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(), + output_bps, output_channels, frames_to_mix); // Set playback delay for newly contributing source if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { @@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix)); // Update output transfer buffer length and pipeline frame count - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } else { // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker @@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } else { // Speaker has finished writing the current audio, update the stream information and restart the speaker this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_, active_stream_info.get_sample_rate()); this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); this_mixer->output_speaker_->start(); @@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) { speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); } - const int16_t *primary_buffer = reinterpret_cast(audio_sources_with_data[0]->data()); + const uint8_t *primary_buffer = audio_sources_with_data[0]->data(); audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - // Mix two streams together + // Mix two streams together at a time, accumulating into the output buffer. for (size_t i = 1; i < audio_sources_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(audio_sources_with_data[i]->data()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::mixer::mix_frames( + primary_buffer, static_cast(primary_stream_info.get_bits_per_sample() / 8), + primary_stream_info.get_channels(), audio_sources_with_data[i]->data(), + static_cast(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8), + speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(), + output_bps, output_channels, frames_to_mix); if (i != audio_sources_with_data.size() - 1) { // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); + primary_buffer = output_transfer_buffer->get_buffer_end(); + primary_stream_info = output_info; } } @@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f57bead679..f1ae919b50 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,6 +11,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#include // esp-audio-libs + #include #include @@ -22,7 +24,8 @@ namespace esphome::mixer_speaker { * - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread * safety * - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker. - * - Audio sent to the SourceSpeaker must have 16 bits per sample. + * - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output + * speaker's bit depth as it is mixed (or copied) into the output buffer. * - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match * the number of channels required for the output speaker. * - In queue mode, the audio sent to the SourceSpeakers can have different sample rates. @@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component { void enter_stopping_state_(); void send_command_(uint32_t command_bit, bool wake_loop = false); - /// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually - /// over a specified amount of samples. - /// @param input_buffer buffer with audio samples to be ducked in place - /// @param input_samples_to_duck number of samples to process in ``input_buffer`` - /// @param current_ducking_db_reduction pointer to the current dB reduction - /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the - /// transition is finished - /// @param samples_per_ducking_step total number of samples per ducking step for the transition - /// @param db_change_per_ducking_step the change in dB reduction per step - static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction, - uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step, - int8_t db_change_per_ducking_step); - MixerSpeaker *parent_; std::shared_ptr audio_source_; @@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { bool pause_state_{false}; - int8_t target_ducking_db_reduction_{0}; - int8_t current_ducking_db_reduction_{0}; - int8_t db_change_per_ducking_step_{1}; - uint32_t ducking_transition_samples_remaining_{0}; - uint32_t samples_per_ducking_step_{0}; + esp_audio_libs::ducking::DuckingState ducking_state_{}; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing @@ -143,12 +129,14 @@ class MixerSpeaker : public Component { /// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information /// @param stream_info The calling source speaker's audio stream information - /// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample - /// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream + /// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream /// ESP_OK if the incoming stream is compatible and the mixer task starts esp_err_t start(audio::AudioStreamInfo &stream_info); void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; } + void set_output_bits_per_sample(uint8_t output_bits_per_sample) { + this->output_bits_per_sample_ = output_bits_per_sample; + } void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; } void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } @@ -159,33 +147,6 @@ class MixerSpeaker : public Component { uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); } protected: - /// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels - /// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has - /// less channels, the extra channel input samples are dropped. - /// @param input_buffer - /// @param input_stream_info - /// @param output_buffer - /// @param output_stream_info - /// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer - static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer, - audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer); - - /// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary - /// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number - /// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample - /// overflows. - /// @param primary_buffer samples buffer for the primary stream - /// @param primary_stream_info stream info for the primary stream - /// @param secondary_buffer samples buffer for secondary stream - /// @param secondary_stream_info stream info for the secondary stream - /// @param output_buffer buffer for the mixed samples - /// @param output_stream_info stream info for the output buffer - /// @param frames_to_mix number of frames in the primary and secondary buffers to mix together - static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix); - static void audio_mixer_task(void *params); EventGroupHandle_t event_group_{nullptr}; @@ -193,6 +154,7 @@ class MixerSpeaker : public Component { FixedVector source_speakers_; speaker::Speaker *output_speaker_{nullptr}; + uint8_t output_bits_per_sample_; uint8_t output_channels_; bool queue_mode_; bool task_stack_in_psram_{false}; diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index e171b9499c..ef613b82bc 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -16,8 +16,12 @@ speaker: id: speaker_id dac_type: external i2s_dout_pin: ${dout_pin} + bits_per_sample: 32bit + channel: stereo - platform: mixer output_speaker: speaker_id + bits_per_sample: 32 + num_channels: 2 source_speakers: - id: source_speaker_1_id - id: source_speaker_2_id From 5cb7e622415ca036f8b33a139ea5db871eabc27d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:33:32 -0400 Subject: [PATCH 113/282] [audio] Use RingBufferAudioSource for decoding (#16564) --- esphome/components/audio/audio_decoder.cpp | 83 +++++++++++----------- esphome/components/audio/audio_decoder.h | 9 +-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index d4ff59fc36..f709c23fb6 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -9,9 +9,12 @@ namespace esphome::audio { static const char *const TAG = "audio.decoder"; -static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data +// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block, +// before yielding and returning. +static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32; + static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10; AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) @@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) } esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { - auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); + // Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte + // aligned, so no frame alignment is required. + auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_); if (source == nullptr) { - return ESP_ERR_NO_MEM; + // create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size) + return ESP_ERR_INVALID_ARG; } - source->set_source(input_ring_buffer); this->input_buffer_ = std::move(source); return ESP_OK; } @@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } FileDecoderState state = FileDecoderState::MORE_TO_PROCESS; - - uint32_t decoding_start = millis(); - - bool first_loop_iteration = true; - - size_t bytes_processed = 0; - size_t bytes_available_before_processing = 0; + uint8_t no_output_iterations = 0; while (state == FileDecoderState::MORE_TO_PROCESS) { // Transfer decoded out @@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { this->playback_ms_ += this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_); } + + if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) { + // All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before + // decoding the next batch + return AudioDecoderState::DECODING; + } } else { // If paused, block to avoid wasting CPU resources delay(READ_WRITE_TIMEOUT_MS); } - // Verify there is enough space to store more decoded audio and that the function hasn't been running too long - if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) || - (millis() - decoding_start > DECODING_TIMEOUT_MS)) { + if (this->output_transfer_buffer_->available() > 0) { + // Output transfer buffer indicates backpressure, return so caller can handle other events; + // e.g., stop/pause, before trying again return AudioDecoderState::DECODING; } - // Decode more audio - - // Never shift the input buffer; every decoder buffers internally and consumes only what it processed. - size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - - if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) { - // Less data is available than what was processed in last iteration, so don't attempt to decode. - // This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer - // will shift the remaining data to the start and copy more from the source the next time the decode function is - // called - break; + // Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output + // stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this + // loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and + // the return keeps stop/pause responsive. + if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) { + delay(1); + return AudioDecoderState::DECODING; } - bytes_available_before_processing = this->input_buffer_->available(); + // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it + // processed, so the source does not need to accumulate or stitch chunks across fill() calls. + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) { - // Failed to decode in last attempt and there is no new data + const size_t available_before_decode = this->input_buffer_->available(); - if ((this->input_buffer_->free() == 0) && first_loop_iteration) { - // The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact - // same data, we can never recover. For const sources this is correct: the entire file is already available, so - // a decode failure is genuine, not a transient out-of-data condition. - state = FileDecoderState::FAILED; - } else { - // Attempt to get more data next time - state = FileDecoderState::IDLE; - } - } else if (this->input_buffer_->available() == 0) { + if (available_before_decode == 0) { // No data to decode, attempt to get more data next time state = FileDecoderState::IDLE; } else { @@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } } - first_loop_iteration = false; - bytes_processed = bytes_available_before_processing - this->input_buffer_->available(); - if (state == FileDecoderState::POTENTIALLY_FAILED) { ++this->potentially_failed_count_; } else if (state == FileDecoderState::END_OF_FILE) { @@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } else if (state == FileDecoderState::FAILED) { return AudioDecoderState::FAILED; } else if (state == FileDecoderState::MORE_TO_PROCESS) { - this->potentially_failed_count_ = 0; + // Reset the failsafe only when the iteration made forward progress: input was consumed or output was + // produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A + // MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it + // toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever. + if ((this->input_buffer_->available() < available_before_decode) || + (this->output_transfer_buffer_->available() > 0)) { + this->potentially_failed_count_ = 0; + } else { + ++this->potentially_failed_count_; + } } } return AudioDecoderState::DECODING; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index c34ebbc613..e772b7eb5f 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -61,15 +61,16 @@ class AudioDecoder { */ public: /// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source() - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioDecoder(size_t input_buffer_size, size_t output_buffer_size); ~AudioDecoder() = default; - /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr. + /// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource. + /// @param input_ring_buffer weak_ptr of the source ring buffer to read from + /// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr. From 747787ae98f3a19819aa1ecbbecafca558435267 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:15 -0400 Subject: [PATCH 114/282] [audio] Use RingBufferAudioSource for resampling (#16560) --- esphome/components/audio/audio_resampler.cpp | 61 +++++++++++++++----- esphome/components/audio/audio_resampler.h | 17 +++--- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index c04cc881f5..bef62ce190 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20; AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size) : input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) { - this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size); this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { - if (this->input_transfer_buffer_ != nullptr) { - this->input_transfer_buffer_->set_source(input_ring_buffer); - return ESP_OK; + // The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring + // buffer (stored here) and the input stream info (set by start()) are available, in either order. + this->source_ring_buffer_ = input_ring_buffer.lock(); + if (this->source_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; } - return ESP_ERR_NO_MEM; + return ESP_OK; } esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { @@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI this->input_stream_info_ = input_stream_info; this->output_stream_info_ = output_stream_info; - if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) { + if (this->output_transfer_buffer_ == nullptr) { return ESP_ERR_NO_MEM; } @@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI return ESP_ERR_NOT_SUPPORTED; } + // Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks + // the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + return ESP_ERR_NOT_SUPPORTED; + } + if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) || (input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) { this->resampler_ = make_unique( @@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI } AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) { + if (this->audio_source_ == nullptr) { + // Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are + // never split across the ring buffer's wrap boundary. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + // Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would + // truncate it and could yield a source that tears frames. + return AudioResamplerState::FAILED; + } + // Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our + // reference only after the source has taken ownership. + this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_, + static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + return AudioResamplerState::FAILED; + } + this->source_ring_buffer_.reset(); + } + if (stop_gracefully) { - if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { + if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { return AudioResamplerState::FINISHED; } } @@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); + // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource + // (there is no intermediate transfer buffer to compact). + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if (this->input_transfer_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No samples available to process return AudioResamplerState::RESAMPLING; } @@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_free = this->output_transfer_buffer_->free(); const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free); - const size_t bytes_available = this->input_transfer_buffer_->available(); + const size_t bytes_available = this->audio_source_->available(); const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available); if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) || (this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) { // Adjust gain by -3 dB to avoid clipping due to the resampling process esp_audio_libs::resampler::ResamplerResults results = - this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(), - this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3); + this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(), + frames_available, frames_free, -3); - this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used)); + this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used)); this->output_transfer_buffer_->increase_buffer_length( this->output_stream_info_.frames_to_bytes(results.frames_generated)); @@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free), this->input_stream_info_.frames_to_bytes(frames_available)); - std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), - (void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer); + std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(), + bytes_to_transfer); - this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer); + this->audio_source_->consume(bytes_to_transfer); this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer); } diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index 575ad13692..c09070c0ce 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -22,7 +22,7 @@ namespace esphome::audio { enum class AudioResamplerState : uint8_t { RESAMPLING, // More data is available to resample FINISHED, // All file data has been resampled and transferred - FAILED, // Unused state included for consistency among Audio classes + FAILED, // Failed to allocate the audio source }; class AudioResampler { @@ -32,14 +32,16 @@ class AudioResampler { * component). Also supports converting bits per sample. */ public: - /// @brief Allocates the input and output transfer buffers - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @brief Allocates the output transfer buffer. The input source is created later in resample(). + /// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioResampler(size_t input_buffer_size, size_t output_buffer_size); - /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy + /// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first + /// resample() call, so add_source() and start() may be called in any order. + /// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership + /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr. @@ -78,7 +80,8 @@ class AudioResampler { void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; } protected: - std::unique_ptr input_transfer_buffer_; + std::shared_ptr source_ring_buffer_; + std::unique_ptr audio_source_; std::unique_ptr output_transfer_buffer_; size_t input_buffer_size_; From 9fcb638f33fcd8814cb4aa5fb2817b5480b73be8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 24 May 2026 15:34:51 -0400 Subject: [PATCH 115/282] [micro_wake_word] Use RingBufferAudioSource (#16595) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 108 ++++++++++-------- .../micro_wake_word/micro_wake_word.h | 17 +-- 2 files changed, 71 insertions(+), 54 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6877e9e5df..739d64dc28 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072; static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { - COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), @@ -114,13 +115,13 @@ void MicroWakeWord::setup() { } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() > 1) { - size_t bytes_free = temp_ring_buffer->free(); - - if (bytes_free < data.size()) { - xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER); - temp_ring_buffer->reset(); + // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task + // to drain it - reset() is a consumer operation and must run on the inference task's thread. + // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. + if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) { + xEventGroupSetBits(this->event_group_, + EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER); } - temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) { { // Ensures any C++ objects fall out of scope to deallocate before deleting the task - const size_t new_bytes_to_process = - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_); - std::unique_ptr audio_buffer; + const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_); + std::unique_ptr audio_source; int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]; if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate audio transfer buffer - audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process); - - if (audio_buffer == nullptr) { + // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes, + static_cast(bytes_per_frame)); + if (audio_source == nullptr) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + this_mww->ring_buffer_ = temp_ring_buffer; + } } } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate ring buffer - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); - } - audio_buffer->set_source(temp_ring_buffer); - this_mww->ring_buffer_ = temp_ring_buffer; - } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { this_mww->microphone_source_->start(); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); - while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) { - audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); - - if (audio_buffer->available() < new_bytes_to_process) { - // Insufficient data to generate new spectrogram features, read more next iteration - continue; + while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { + // Producer asked us to drain; run the consumer-side reset from this thread. + audio_source->clear_buffered_data(); + xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - // Generate new spectrogram features - uint32_t processed_samples = this_mww->generate_features_( - (int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer); - audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t)); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); - // Run inference using the new spectorgram features - if (!this_mww->update_model_probabilities_(features_buffer)) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); - break; + // The frontend buffers samples internally and only emits a feature once it has a full window, so we can + // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() + // strictly decreases and this loop always terminates. + while (audio_source->available() >= sizeof(int16_t)) { + const size_t samples_available = audio_source->available() / sizeof(int16_t); + const int16_t *audio_data = reinterpret_cast(audio_source->data()); + + size_t processed_samples = 0; + const bool feature_generated = + this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples); + audio_source->consume(processed_samples * sizeof(int16_t)); + + if (feature_generated) { + if (!this_mww->update_model_probabilities_(features_buffer)) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); + break; + } + + // Process each model's probabilities and possibly send a Detection Event to the queue + this_mww->process_probabilities_(); + } } - - // Process each model's probabilities and possibly send a Detection Event to the queue - this_mww->process_probabilities_(); } } } @@ -386,11 +396,15 @@ void MicroWakeWord::set_state_(State state) { } } -size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) { - size_t processed_samples = 0; +bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) { + *processed_samples = 0; struct FrontendOutput frontend_output = - FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples); + FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples); + + if (frontend_output.size == 0) { + return false; + } for (size_t i = 0; i < frontend_output.size; ++i) { // These scaling values are set to match the TFLite audio frontend int8 output. @@ -415,7 +429,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a features_buffer[i] = static_cast(clamp(value, INT8_MIN, INT8_MAX)); } - return processed_samples; + return true; } void MicroWakeWord::process_probabilities_() { diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5c0c056ac0..ef440b5d37 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -115,13 +115,16 @@ class MicroWakeWord : public Component void set_state_(State state); - /// @brief Generates spectrogram features from an input buffer of audio samples - /// @param audio_buffer (int16_t *) Buffer containing input audio samples - /// @param samples_available (size_t) Number of samples avaiable in the input buffer - /// @param features_buffer (int8_t *) Buffer to store generated features - /// @return (size_t) Number of samples processed from the input buffer - size_t generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]); + /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples + /// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have + /// accumulated to fill a full analysis window. + /// @param audio_buffer (const int16_t *) Buffer containing input audio samples + /// @param samples_available (size_t) Number of samples available in the input buffer + /// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true + /// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer + /// @return True if a new feature was generated; false if more samples are required + bool generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples); /// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent /// to the detection_queue_. From 090f5a486a63418c54a0f01b0e9a78ae61ec25ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 16:32:47 -0500 Subject: [PATCH 116/282] Lift dependabot pip open PR limit (#16609) --- .github/dependabot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 528e69c478..e87939f824 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: directory: "/" schedule: interval: daily + open-pull-requests-limit: 10 ignore: # Hypotehsis is only used for testing and is updated quite often - dependency-name: hypothesis From 917ffc379795d9f42c5d92e46834811d056e3774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 21:49:52 +0000 Subject: [PATCH 117/282] Bump aioesphomeapi from 45.0.4 to 45.2.2 (#16611) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 178e05497f..45401a7995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.4 +aioesphomeapi==45.2.2 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 62b0a93e5e032d1ad4573cb055df1e50e6a5af56 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 May 2026 10:43:39 +1200 Subject: [PATCH 118/282] [rp2040] Add variant config option for RP2040/RP2350 (#16602) --- esphome/components/rp2040/__init__.py | 109 +++++++++++++++++- esphome/components/rp2040/const.py | 26 +++++ esphome/core/defines.h | 1 + tests/components/rp2040/test.rp2040-ard.yaml | 1 + .../rp2040/test.rp2040-pico2-ard.yaml | 6 + tests/unit_tests/components/test_rp2040.py | 67 ++++++++++- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 tests/components/rp2040/test.rp2040-pico2-ard.yaml diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 862d532645..830c961476 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,8 +1,10 @@ +from collections.abc import Callable import logging from pathlib import Path import re from string import ascii_letters, digits import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -12,6 +14,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_VARIANT, CONF_VERSION, CONF_WATCHDOG_TIMEOUT, KEY_CORE, @@ -21,12 +24,30 @@ from esphome.const import ( PLATFORM_RP2040, ThreadModel, ) -from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeCore, + EsphomeError, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed +from esphome.types import ConfigType from . import boards -from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import ( + KEY_BOARD, + KEY_LWIP_OPTS, + KEY_PIO_FILES, + KEY_RP2040, + KEY_VARIANT, + MCU_TO_VARIANT, + STANDARD_BOARDS, + VARIANT_FRIENDLY, + VARIANTS, + rp2040_ns, +) # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -68,7 +89,7 @@ def board_id_has_wifi(board_id: str) -> bool: return board_info.get("wifi", False) -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_RP2040] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -76,12 +97,46 @@ def set_core_data(config): config[CONF_FRAMEWORK][CONF_VERSION] ) CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} return config +def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: + return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + + +def only_on_variant( + *, + supported: str | list[str] | None = None, + unsupported: str | list[str] | None = None, + msg_prefix: str = "This feature", +) -> Callable[[Any], Any]: + """Config validator for features only available on some RP2040 variants.""" + if supported is not None and not isinstance(supported, list): + supported = [supported] + if unsupported is not None and not isinstance(unsupported, list): + unsupported = [unsupported] + + def validator_(obj: Any) -> Any: + if not CORE.is_rp2040: + raise cv.Invalid(f"{msg_prefix} is only available on RP2040") + variant = get_rp2040_variant() + if supported is not None and variant not in supported: + raise cv.Invalid( + f"{msg_prefix} is only available on {', '.join(supported)}" + ) + if unsupported is not None and variant in unsupported: + raise cv.Invalid( + f"{msg_prefix} is not available on {', '.join(unsupported)}" + ) + return obj + + return validator_ + + def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. @@ -192,12 +247,52 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( _arduino_check_versions, ) + +def _detect_variant(value: ConfigType) -> ConfigType: + value = value.copy() + board: str | None = value.get(CONF_BOARD) + variant: str | None = value.get(CONF_VARIANT) + + if board is None: + # `cv.has_at_least_one_key` guarantees variant is set here. + board = STANDARD_BOARDS[variant] + value[CONF_BOARD] = board + + board_info = boards.BOARDS.get(board) + if board_info is None: + if variant is None: + raise cv.Invalid( + "This board is unknown; please specify the chip variant using " + f"the '{CONF_VARIANT}' option.", + path=[CONF_BOARD], + ) + _LOGGER.warning( + "This board is unknown; the specified variant '%s' will be used " + "but this may not work as expected.", + variant, + ) + else: + board_variant = MCU_TO_VARIANT[board_info["mcu"]] + if variant is None: + variant = board_variant + elif variant != board_variant: + raise cv.Invalid( + f"Option '{CONF_VARIANT}' ({variant}) does not match the " + f"selected board '{board}' ({board_variant}).", + path=[CONF_VARIANT], + ) + + value[CONF_VARIANT] = variant + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.All( + cv.Optional(CONF_BOARD): cv.All( cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), + cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, @@ -206,6 +301,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), + cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), + _detect_variant, set_core_data, ) @@ -223,7 +320,9 @@ async def to_code(config): cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) - cg.add_define("ESPHOME_VARIANT", "RP2040") + variant = config[CONF_VARIANT] + cg.add_build_flag(f"-DUSE_RP2040_VARIANT_{variant}") + cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index e381d0482d..959753d95b 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -4,5 +4,31 @@ KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" +KEY_VARIANT = "variant" + +VARIANT_RP2040 = "RP2040" +VARIANT_RP2350 = "RP2350" +VARIANTS = [ + VARIANT_RP2040, + VARIANT_RP2350, +] + +VARIANT_FRIENDLY = { + VARIANT_RP2040: "RP2040", + VARIANT_RP2350: "RP2350", +} + +# Map BOARDS[board]["mcu"] (lowercase) to canonical variant constant +MCU_TO_VARIANT = { + "rp2040": VARIANT_RP2040, + "rp2350": VARIANT_RP2350, +} + +# Default board chosen when only `variant` is specified — the Raspberry Pi +# Foundation reference boards (Pico W / Pico 2 W). +STANDARD_BOARDS = { + VARIANT_RP2040: "rpipicow", + VARIANT_RP2350: "rpipico2w", +} rp2040_ns = cg.esphome_ns.namespace("rp2040") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ee8e89de8b..3cb92616bb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -401,6 +401,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET #define USE_ETHERNET diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2040/test.rp2040-ard.yaml index 1eb315a3b4..09531f914e 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2040/test.rp2040-ard.yaml @@ -1,4 +1,5 @@ rp2040: + variant: rp2040 enable_full_printf: false logger: diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2040/test.rp2040-pico2-ard.yaml new file mode 100644 index 0000000000..c9d795840d --- /dev/null +++ b/tests/components/rp2040/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +rp2040: + variant: rp2350 + enable_full_printf: false + +logger: + level: VERBOSE diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py index 25a9ade567..8e726933ed 100644 --- a/tests/unit_tests/components/test_rp2040.py +++ b/tests/unit_tests/components/test_rp2040.py @@ -1,6 +1,11 @@ -"""Tests for RP2040 component public helpers.""" +"""Tests for RP2040 component public helpers and variant detection.""" -from esphome.components.rp2040 import board_id_has_wifi +import pytest + +from esphome.components.rp2040 import _detect_variant, board_id_has_wifi +from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_VARIANT def test_board_id_has_wifi_for_known_wifi_board() -> None: @@ -27,3 +32,61 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: "no CYW43" guard at compile time. """ assert board_id_has_wifi("not-a-real-board-id") is True + + +def test_detect_variant_derives_variant_from_board() -> None: + """Board alone resolves to the matching variant.""" + result = _detect_variant({CONF_BOARD: "rpipicow"}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_derives_variant_from_rp2350_board() -> None: + """An RP2350 board resolves to ``RP2350``.""" + result = _detect_variant({CONF_BOARD: "rpipico2"}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_only_picks_default_board_rp2040() -> None: + """Variant alone picks Pico W as the canonical RP2040 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) + assert result[CONF_BOARD] == "rpipicow" + assert result[CONF_VARIANT] == VARIANT_RP2040 + + +def test_detect_variant_only_picks_default_board_rp2350() -> None: + """Variant alone picks Pico 2 W as the canonical RP2350 board.""" + result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2w" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_matching_explicit_variant_passes() -> None: + """Specifying both a board and the matching variant is allowed.""" + result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) + assert result[CONF_BOARD] == "rpipico2" + assert result[CONF_VARIANT] == VARIANT_RP2350 + + +def test_detect_variant_mismatched_variant_raises() -> None: + """Board/variant mismatch must be rejected and name the offending board.""" + with pytest.raises( + cv.Invalid, match=r"does not match the selected board 'rpipicow'" + ): + _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) + + +def test_detect_variant_unknown_board_without_variant_raises() -> None: + """Unknown board with no variant tells the user how to recover.""" + with pytest.raises(cv.Invalid, match="please specify the chip variant"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) + + +def test_detect_variant_unknown_board_with_variant_passes() -> None: + """Unknown board + explicit variant is accepted (with a warning).""" + result = _detect_variant( + {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} + ) + assert result[CONF_BOARD] == "not-a-real-board" + assert result[CONF_VARIANT] == VARIANT_RP2040 From e0167e9bdff94e5a0aace72fbcb010ea1d9022d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 May 2026 20:17:51 -0500 Subject: [PATCH 119/282] [lvgl] Memoize obj_schema by widget_type (#16615) --- esphome/components/lvgl/schemas.py | 14 +++- .../lvgl/test_obj_schema_cache.py | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/test_obj_schema_cache.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 58ef88d6a8..7436581fb4 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -462,13 +462,23 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Widget types are module-level singletons populated at import time, so we +# can cache compiled obj_schemas by widget_type identity for the lifetime of +# the process. The strong reference in the value keeps the key (an id() +# target) from being recycled. +_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} + + def obj_schema(widget_type: WidgetType): """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: :return: """ - return ( + cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + schema = ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) .extend(automation_schema(widget_type.w_type)) @@ -479,6 +489,8 @@ def obj_schema(widget_type: WidgetType): } ) ) + _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) + return schema ALIGN_TO_SCHEMA = { diff --git a/tests/component_tests/lvgl/test_obj_schema_cache.py b/tests/component_tests/lvgl/test_obj_schema_cache.py new file mode 100644 index 0000000000..860ee211dd --- /dev/null +++ b/tests/component_tests/lvgl/test_obj_schema_cache.py @@ -0,0 +1,67 @@ +"""Tests for obj_schema() memoization.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import WIDGET_TYPES, obj_schema + + +@pytest.fixture(autouse=True) +def _clear_obj_schema_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_SCHEMA_CACHE", None) + if cache is not None: + cache.clear() + yield + if cache is not None: + cache.clear() + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_same_widget_type_returns_same_schema() -> None: + wt = _widget_type("obj") + assert obj_schema(wt) is obj_schema(wt) + + +def test_different_widget_types_return_different_schemas() -> None: + assert obj_schema(_widget_type("obj")) is not obj_schema(_widget_type("label")) + + +def test_cache_is_populated_after_first_call() -> None: + wt = _widget_type("obj") + assert id(wt) not in lvgl_schemas._OBJ_SCHEMA_CACHE + obj_schema(wt) + assert id(wt) in lvgl_schemas._OBJ_SCHEMA_CACHE + + +def test_cached_schema_produces_equivalent_output() -> None: + wt = _widget_type("obj") + cached_result = obj_schema(wt)({}) + lvgl_schemas._OBJ_SCHEMA_CACHE.clear() + fresh_result = obj_schema(wt)({}) + assert cached_result == fresh_result + + +def test_id_recycling_is_caught_by_identity_guard() -> None: + wt = _widget_type("obj") + real_schema = obj_schema(wt) + + cached_widget_type, _ = lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] + sentinel_schema = object() + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (cached_widget_type, sentinel_schema) + assert obj_schema(wt) is sentinel_schema + + other = _widget_type("label") + lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (other, sentinel_schema) + rebuilt = obj_schema(wt) + assert rebuilt is not sentinel_schema + assert rebuilt is not real_schema From e7ab78366d184a912f5f76d717f6aa387b7cd21c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:03:38 -0400 Subject: [PATCH 120/282] [core] Add esphome.build_flags option for IDF + PlatformIO (#16629) --- esphome/const.py | 1 + esphome/core/config.py | 11 +++++++++++ script/ci-custom.py | 2 +- tests/components/esphome/common.yaml | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index 9dd77a7cb8..07f6bad771 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -199,6 +199,7 @@ CONF_BROKER = "broker" CONF_BSSID = "bssid" CONF_BUFFER_DURATION = "buffer_duration" CONF_BUFFER_SIZE = "buffer_size" +CONF_BUILD_FLAGS = "build_flags" CONF_BUILD_PATH = "build_path" CONF_BUS_VOLTAGE = "bus_voltage" CONF_BUSY_PIN = "busy_pin" diff --git a/esphome/core/config.py b/esphome/core/config.py index 5a98b94781..6125c4ecc9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_AREA, CONF_AREA_ID, CONF_AREAS, + CONF_BUILD_FLAGS, CONF_BUILD_PATH, CONF_COMMENT, CONF_COMPILE_PROCESS_LIMIT, @@ -288,6 +289,7 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( { cv.string_strict: cv.string, @@ -510,6 +512,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_build_flags(flags: list[str]) -> None: + for flag in flags: + cg.add_build_flag(flag) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_environment_variables(env_vars: dict[str, str]) -> None: # Set environment variables for the build process @@ -705,6 +713,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_BUILD_FLAGS]: + CORE.add_job(_add_build_flags, config[CONF_BUILD_FLAGS]) + if config[CONF_ENVIRONMENT_VARIABLES]: CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) diff --git a/script/ci-custom.py b/script/ci-custom.py index 56ca0d0355..51fea97874 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -562,7 +562,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 = 1012 +CONST_PY_MAX_CONF = 1013 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index db75b08b38..93f82824e6 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,8 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + build_flags: + - "-DESPHOME_TEST_BUILD_FLAG" environment_variables: TEST_ENV_VAR: "test_value" BUILD_NUMBER: "12345" From 98e72133872cd7b198df16e6468dc874224f6f60 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:08:16 -0400 Subject: [PATCH 121/282] [espidf] Warn instead of skipping libraries with framework mismatch (#16630) --- esphome/espidf/component.py | 23 +++++++++++++++++++---- tests/unit_tests/test_espidf_component.py | 11 ++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index a452a3f34a..3534ac82f5 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -610,11 +610,17 @@ def _check_library_data(data: dict): """ Check if a library data is compatible with the ESP-IDF framework. + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidIDFComponent`` so the caller skips the library. A framework + mismatch only logs a warning — PIO manifests often understate the + frameworks they actually compile under, and IDF (unlike PIO's + ``lib_compat_mode``) has no opt-out, so we include the library anyway. + Args: - component: IDFComponent object being processed + data: PIO library manifest dict being processed. Raises: - ValueError: If library has unsupported platforms or frameworks + InvalidIDFComponent: If the library does not support the ESP32 platform. """ platforms = data.get("platforms", "*") if isinstance(platforms, str): @@ -632,12 +638,21 @@ def _check_library_data(data: dict): frameworks = [a.strip() for a in frameworks.split(",")] frameworks = _ensure_list(frameworks) - # Check if library supports ESP-IDF framework + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to + # opt out of the check. Warn instead of failing so the user isn't forced to + # fork the library to fix the manifest. framework = "arduino" if CORE.using_arduino else "espidf" valid_framework = "*" in frameworks or framework in frameworks if not valid_framework: - raise InvalidIDFComponent(f"Unsupported library frameworks: {frameworks}") + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) def _process_dependencies(component: IDFComponent): diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 7d6c861ffd..f50f5317de 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -261,9 +261,14 @@ def test_check_library_data_invalid_platform(esp32_idf_core): _check_library_data({"platforms": ["other"], "frameworks": "*"}) -def test_check_library_data_invalid_framework(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": "*", "frameworks": ["other"]}) +def test_check_library_data_invalid_framework( + esp32_idf_core: None, caplog: pytest.LogCaptureFixture +) -> None: + # Framework mismatch is a warning, not a hard skip: the library is still + # included so that PIO manifests that only list "arduino" (but actually + # compile under IDF) can be used without forking them. + _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) + assert "do not include 'espidf'" in caplog.text def test_extra_script_captures_libpath_libs_and_defines(tmp_path): From cde52ef75e3c9f633883719f1c15b79041ca9efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 09:09:54 -0500 Subject: [PATCH 122/282] [lvgl] Merge dict-extend chains to speed up schema construction (#16614) --- esphome/components/lvgl/__init__.py | 27 +- esphome/components/lvgl/schemas.py | 77 ++++-- .../lvgl/test_schema_dict_helpers.py | 236 ++++++++++++++++++ 3 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 tests/component_tests/lvgl/test_schema_dict_helpers.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 4277c14dd7..44bcda9ba9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,3 +1,4 @@ +import functools import importlib from pathlib import Path import pkgutil @@ -79,7 +80,7 @@ from .schemas import ( WIDGET_TYPES, any_widget_schema, container_schema, - obj_schema, + obj_dict, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -518,16 +519,32 @@ def add_hello_world(config): return config -def _theme_schema(value): +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, widgets.WidgetType], ...], +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() in + # schemas.py for why chained .extend() is avoided here. return cv.Schema( { cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, **{ - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types }, } - )(value) + ) + + +def _theme_schema(value: dict) -> dict: + return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 7436581fb4..b901eb4b53 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -378,15 +378,33 @@ TRIGGER_EVENT_MAP = { } -def part_schema(parts): +def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]: + """ + Return the raw mapping used by part_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls (each .extend() recompiles the + whole mapping, turning the build into O(N^2)). + + Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the + nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and + required=False and must not register any add_extra/prepend_extra + validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps + only the mapping; non-default extra/required and any _extra_schemas would + be silently dropped. + """ + return { + **STATE_SCHEMA.schema, + **FLAG_SCHEMA.schema, + **{cv.Optional(part): STATE_SCHEMA for part in parts}, + } + + +def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type :param parts: The parts to include :return: The schema """ - return STATE_SCHEMA.extend(FLAG_SCHEMA).extend( - {cv.Optional(part): STATE_SCHEMA for part in parts} - ) + return cv.Schema(part_dict(parts)) def automation_schema(typ: LvType): @@ -462,6 +480,43 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema +# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema(). +# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on +# every call, so without this cache _theme_schema pays that cost per widget +# per validation. Callers must treat the returned dict as immutable. The +# _theme_schema caller spreads it into a fresh dict, which is safe; the +# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores +# the mapping by reference but never mutates it (.extend() copies first), so +# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema +# would corrupt this cache. +_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {} + + +def obj_dict(widget_type: WidgetType) -> dict[Any, Any]: + """ + Return the raw mapping used by obj_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls. + + Inherits the same source-schema invariant documented on part_dict: any + schema spread into this mapping must use the default extra=PREVENT_EXTRA + and required=False and must carry no add_extra/prepend_extra validators. + + The returned mapping is cached and must be treated as immutable by callers. + """ + cached = _OBJ_DICT_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + built = { + **part_dict(widget_type.parts), + **ALIGN_TO_SCHEMA, + **automation_schema(widget_type.w_type), + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), + } + _OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built) + return built + + # Widget types are module-level singletons populated at import time, so we # can cache compiled obj_schemas by widget_type identity for the lifetime of # the process. The strong reference in the value keeps the key (an id() @@ -469,7 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): _OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} -def obj_schema(widget_type: WidgetType): +def obj_schema(widget_type: WidgetType) -> cv.Schema: """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: @@ -478,17 +533,7 @@ def obj_schema(widget_type: WidgetType): cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) if cached is not None and cached[0] is widget_type: return cached[1] - schema = ( - part_schema(widget_type.parts) - .extend(ALIGN_TO_SCHEMA) - .extend(automation_schema(widget_type.w_type)) - .extend( - { - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), - } - ) - ) + schema = cv.Schema(obj_dict(widget_type)) _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) return schema diff --git a/tests/component_tests/lvgl/test_schema_dict_helpers.py b/tests/component_tests/lvgl/test_schema_dict_helpers.py new file mode 100644 index 0000000000..16714f54d7 --- /dev/null +++ b/tests/component_tests/lvgl/test_schema_dict_helpers.py @@ -0,0 +1,236 @@ +"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts. + +These guard the dict-merge refactor: the dict helpers must keep returning the +same logical mapping as the chained-extend version produced, and the +corresponding Schema(...) wrappers must accept and reject the same configs. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv +import esphome.components.lvgl +from esphome.components.lvgl import ( + _theme_schema, + defines as df, + schemas as lvgl_schemas, +) +from esphome.components.lvgl.schemas import ( + ALIGN_TO_SCHEMA, + FLAG_SCHEMA, + FULL_STYLE_SCHEMA, + STATE_SCHEMA, + STYLE_SCHEMA, + WIDGET_TYPES, + automation_schema, + obj_dict, + obj_schema, + part_dict, + part_schema, +) +from esphome.components.lvgl.types import LvType +from esphome.components.lvgl.widgets import WidgetType + + +@pytest.fixture(autouse=True) +def _clear_obj_dict_cache() -> Generator[None]: + cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None) + if cache is not None: + cache.clear() + # The lazily-built theme schema is cached on _build_theme_schema; clear it + # too so each test starts from a clean slate. + build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None) + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + yield + if cache is not None: + cache.clear() + if build_theme is not None and hasattr(build_theme, "cache_clear"): + build_theme.cache_clear() + + +def _marker_names(mapping) -> set[str]: + """Return the underlying string names of every voluptuous Marker key.""" + names: set[str] = set() + for key in mapping: + if isinstance(key, vol.Marker): + schema = key.schema + if isinstance(schema, str): + names.add(schema) + return names + + +def _widget_type(name: str = "obj"): + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def test_part_dict_includes_state_flag_and_part_keys() -> None: + parts = ("indicator", "knob") + keys = _marker_names(part_dict(parts)) + + assert {"indicator", "knob"} <= keys + assert _marker_names(STATE_SCHEMA.schema) <= keys + assert _marker_names(FLAG_SCHEMA.schema) <= keys + + +def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None: + wt = _widget_type("obj") + part_keys = _marker_names(part_dict(wt.parts)) + obj_keys = _marker_names(obj_dict(wt)) + + assert part_keys <= obj_keys + assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys + assert _marker_names(automation_schema(wt.w_type)) <= obj_keys + assert {"state", "group"} <= obj_keys + + +def test_obj_dict_is_memoized_by_widget_type() -> None: + wt = _widget_type("obj") + first = obj_dict(wt) + second = obj_dict(wt) + assert first is second + # Different widget type, different dict. + assert obj_dict(_widget_type("label")) is not first + + +def test_part_schema_round_trips_known_state_and_part_settings() -> None: + schema = part_schema(("indicator",)) + out = schema( + { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + "indicator": {"bg_color": 0x778899}, + } + ) + assert out["bg_color"] == 0x112233 + assert out["checked"]["bg_color"] == 0x445566 + assert out["indicator"]["bg_color"] == 0x778899 + + +def test_part_schema_rejects_unknown_part() -> None: + schema = part_schema(("indicator",)) + with pytest.raises(vol.Invalid): + schema({"definitely_not_a_part": {}}) + + +@pytest.mark.parametrize("name", sorted(WIDGET_TYPES)) +def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None: + obj_schema(_widget_type(name))({}) + + +def test_obj_schema_accepts_align_to_and_state_group() -> None: + schema = obj_schema(_widget_type("obj")) + out = schema( + { + df.CONF_ALIGN_TO: { + "id": "some_other_widget", + df.CONF_ALIGN: "TOP_LEFT", + }, + "state": {"checked": True}, + } + ) + assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT" + assert out["state"]["checked"] is True + + +def test_obj_schema_rejects_unknown_top_level_key() -> None: + with pytest.raises(vol.Invalid): + obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1}) + + +def test_part_schema_returns_cv_schema_for_extend_callers() -> None: + schema = part_schema(("indicator",)) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + out = extended({"extra_key": "value", "bg_color": 0xAABBCC}) + assert out["extra_key"] == "value" + assert out["bg_color"] == 0xAABBCC + + +def test_obj_schema_returns_cv_schema_for_extend_callers() -> None: + schema = obj_schema(_widget_type("obj")) + extended = schema.extend({cv.Optional("extra_key"): cv.string}) + extended({"extra_key": "value"}) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None: + # part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...), + # which silently drops _extra_schemas and any non-default extra/required. + # Lock the invariant so a future add_extra() on these sources fails CI + # instead of quietly removing validation from part/obj/theme schemas. + assert not schema._extra_schemas + assert schema.extra is vol.PREVENT_EXTRA + assert schema.required is False + + +def test_theme_schema_merges_obj_dict_and_full_style_props() -> None: + # _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema + # share many STYLE_SCHEMA marker instances. Exercise the merged schema + # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict + # and a FULL_STYLE-only property) to lock the behaviour against future + # regressions in either source. + out = _theme_schema( + { + df.CONF_DARK_MODE: True, + "obj": { + "bg_color": 0x112233, + "checked": {"bg_color": 0x445566}, + df.CONF_PAD_ROW: 4, + df.CONF_GRID_CELL_X_ALIGN: "CENTER", + }, + } + ) + assert out[df.CONF_DARK_MODE] is True + obj_out = out["obj"] + assert obj_out["bg_color"] == 0x112233 + assert obj_out["checked"]["bg_color"] == 0x445566 + assert obj_out[df.CONF_PAD_ROW] == 4 + assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER" + + +def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None: + # _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES. + # any_widget_schema explicitly supports external components registering + # widgets lazily, and the device builder revalidates in-process, so a + # widget registered after first use must invalidate the cached snapshot. + _theme_schema({df.CONF_DARK_MODE: True}) # populate the cache + + name = "test_self_heal_widget" + assert name not in WIDGET_TYPES + # is_mock=True skips registration side-effects; insert into WIDGET_TYPES + # manually so the next theme call sees the new entry. + WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True) + try: + out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) + assert out[name]["bg_color"] == 0x010203 + finally: + WIDGET_TYPES.pop(name, None) + + +@pytest.mark.parametrize( + "schema", + [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], +) +def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None: + # _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key + # collision, dict-spread keeps the first source's marker (and its default) + # but the last source's value, whereas .extend() would take both from the + # later source. The two are equivalent today because the overlapping + # markers are the same instances (both derive from STYLE_SCHEMA) and none + # carry a top-level default. Lock that so a future divergent default would + # fail CI rather than silently drift the merged validation. + offenders = [ + marker.schema + for marker in schema.schema + if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED + ] + assert not offenders, f"top-level Optional with default: {offenders}" From cf1fabe6d4d4e14936ce496fc4445a04ca62654f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:11:31 -0400 Subject: [PATCH 123/282] [esp32_hosted] Bump esp_hosted to 2.12.8 and add use_psram option (#16627) --- esphome/components/esp32_hosted/__init__.py | 10 +++++++++- esphome/idf_component.yml | 2 +- tests/components/esp32_hosted/common.yaml | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 71d1fd3ac1..94e20ea6c9 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 +from esphome.components.const import CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -39,6 +40,7 @@ BASE_SCHEMA = cv.Schema( cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), cv.Required(CONF_ACTIVE_HIGH): cv.boolean, cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean, } ) @@ -242,6 +244,12 @@ async def to_code(config): else: _configure_spi(config) + # Place the transport mempool in PSRAM. Required on memory-tight host + # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM + # mempool allocation fails at boot with `sdio_mempool_create` assert. + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + # Library versions idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" @@ -249,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bc166ff44..5af25fc351 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -36,7 +36,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.7 + version: 2.12.8 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/tests/components/esp32_hosted/common.yaml b/tests/components/esp32_hosted/common.yaml index ab029e5064..332fe5b070 100644 --- a/tests/components/esp32_hosted/common.yaml +++ b/tests/components/esp32_hosted/common.yaml @@ -3,6 +3,7 @@ esp32_hosted: slot: 1 active_high: true reset_pin: GPIO15 + use_psram: true cmd_pin: GPIO13 clk_pin: GPIO12 d0_pin: GPIO11 From 7c494fd3efcc9fc1c1dce0d6fc09d47a44affdfd Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 10:15:51 -0400 Subject: [PATCH 124/282] [psram] Consolidate task stack in PSRAM handling (#16628) --- .../audio_file/media_source/__init__.py | 16 +++----------- esphome/components/audio_http/media_source.py | 18 +++------------ esphome/components/mixer/speaker/__init__.py | 13 +++++------ esphome/components/psram/__init__.py | 22 +++++++++++++++++++ .../components/resampler/speaker/__init__.py | 8 +++---- esphome/components/sendspin/__init__.py | 17 +++----------- .../sendspin/media_source/__init__.py | 5 ++--- .../speaker/media_player/__init__.py | 9 ++------ .../audio_file/validate.esp32-idf.yaml | 11 ++++++++++ 9 files changed, 54 insertions(+), 65 deletions(-) create mode 100644 tests/components/audio_file/validate.esp32-idf.yaml diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py index 635a51b610..0710582813 100644 --- a/esphome/components/audio_file/media_source/__init__.py +++ b/esphome/components/audio_file/media_source/__init__.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioFileMediaSource, ) .extend( { - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 519d8df698..e8acbc81af 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - # Only require the psram component when actually enabling PSRAM stacks; validating - # the boolean first means `false` doesn't trigger the requires_component check. - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( min=5000, max=1000000 ), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 8501843d3f..47164a9997 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -93,7 +93,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on([PLATFORM_ESP32]), @@ -123,12 +123,9 @@ async def to_code(config): cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) - if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): - cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() # Initialize FixedVector with exact count of source speakers cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS]))) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 86c17ce9ca..d36d900997 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -1,5 +1,6 @@ import logging import textwrap +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_IGNORE_NOT_FOUND @@ -94,6 +95,27 @@ def is_guaranteed() -> bool: return CORE.data.get(KEY_PSRAM_GUARANTEED, False) +def request_external_task_stack() -> None: + """Allow FreeRTOS task stacks to be allocated in external RAM (PSRAM). + + Components that expose a ``task_stack_in_psram`` option should call this from their + ``to_code`` when the option is enabled. The sdkconfig option only permits external + stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task. + """ + add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True) + + +def validate_task_stack_in_psram(value: Any) -> bool: + """Validate a ``task_stack_in_psram`` boolean, requiring the psram component only when enabled. + + Validating the boolean first means an explicit ``false`` does not pull in the psram + requirement, so the option can still be set to false on devices without PSRAM. + """ + if value := cv.boolean(value): + return cv.requires_component(DOMAIN)(value) + return value + + def validate_psram_mode(config): esp32_config = fv.full_config.get()[PLATFORM_ESP32] if config[CONF_SPEED] == "120MHZ": diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 3134cf7646..8a13110631 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), cv.Optional(CONF_TAPS, default=16): _validate_taps, } @@ -88,9 +88,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index b670bd3c4d..e8c643f9b9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -121,13 +121,6 @@ def register_player_config(config: ConfigType) -> None: data.player_config = config -def _validate_task_stack_in_psram(value): - value = cv.boolean(value) - if value: - return cv.requires_component(psram.DOMAIN)(value) - return value - - def _request_high_performance_networking(config: ConfigType) -> ConfigType: """Request high performance networking for Sendspin streaming. @@ -152,7 +145,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on_esp32, @@ -201,9 +194,7 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1") @@ -261,9 +252,7 @@ async def to_code(config: ConfigType) -> None: psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) if psram_stack: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not # starved by the HTTP server during the initial encoded-audio burst at stream start), diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index f689ab01cb..6af244d41f 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import media_source +from esphome.components import media_source, psram import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -19,7 +19,6 @@ from .. import ( CONF_SENDSPIN_ID, MEMORY_LOCATIONS, SendspinHub, - _validate_task_stack_in_psram, register_player_config, request_controller_support, sendspin_ns, @@ -71,7 +70,7 @@ CONFIG_SCHEMA = cv.All( ).extend( { cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( cv.positive_time_period_milliseconds, diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 094043c292..90eb19d73d 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -7,7 +7,6 @@ import esphome.codegen as cg from esphome.components import ( audio, audio_file, - esp32, media_player, network, ota, @@ -155,9 +154,7 @@ CONFIG_SCHEMA = cv.All( # Remove before 2026.10.0 cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( - cv.boolean, cv.requires_component(psram.DOMAIN) - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, @@ -198,9 +195,7 @@ async def to_code(config): if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml new file mode 100644 index 0000000000..085f853c8e --- /dev/null +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -0,0 +1,11 @@ +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav + +media_source: + - platform: audio_file + id: audio_file_source + # task_stack_in_psram: false must validate without a psram: component + task_stack_in_psram: false From 684bce8b9a588a0a939673298158b3b9380dc633 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:41 -0400 Subject: [PATCH 125/282] [esp32] Decode crash PCs via IDF toolchain on IDF builds (#16626) --- esphome/components/esp32/__init__.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a06ae89c3e..e3bff8f934 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,7 +46,7 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt, Library from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_component @@ -2658,13 +2658,29 @@ def copy_files(): def _decode_pc(config, addr): - from esphome.platformio import toolchain + # _decode_pc runs from the api log processor's asyncio callback, which + # only catches EsphomeError. Any other exception escaping here tears down + # the protocol and triggers an infinite reconnect/replay loop. Convert + # toolchain-resolution errors (e.g. missing build dir / cmake cache) into + # EsphomeError so the caller can disable decoding cleanly. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except RuntimeError as err: + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except From 1c7ae96e424cadc00bbc9b778fbb2327b8103020 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 11:04:26 -0400 Subject: [PATCH 126/282] [micro_wake_word] Allow task stack to be allocated in PSRAM (#16632) --- .../components/micro_wake_word/__init__.py | 8 ++++++- .../micro_wake_word/micro_wake_word.cpp | 24 +++++++------------ .../micro_wake_word/micro_wake_word.h | 8 ++++++- tests/components/micro_wake_word/common.yaml | 4 ++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 38926fce99..61d296cbba 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition import esphome.codegen as cg -from esphome.components import esp32, microphone, ota +from esphome.components import esp32, microphone, ota, psram import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RAW_DATA_ID, CONF_REF, CONF_REFRESH, + CONF_TASK_STACK_IN_PSRAM, CONF_TYPE, CONF_URL, CONF_USERNAME, @@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." ), @@ -451,6 +453,10 @@ async def to_code(config): cg.add_define("USE_MICRO_WAKE_WORD") ota.request_ota_state_listeners() + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() + esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 739d64dc28..237d72229d 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -217,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) { FrontendFreeStateContents(&this_mww->frontend_state_); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED); - while (true) { - // Continuously delay until the main loop deletes the task - delay(10); - } + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } std::vector MicroWakeWord::get_wake_words() { @@ -243,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit #endif void MicroWakeWord::suspend_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskSuspend(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskSuspend(this->inference_task_.get_handle()); } } void MicroWakeWord::resume_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskResume(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskResume(this->inference_task_.get_handle()); } } @@ -292,8 +289,7 @@ void MicroWakeWord::loop() { if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - vTaskDelete(this->inference_task_handle_); - this->inference_task_handle_ = nullptr; + this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); @@ -311,7 +307,7 @@ void MicroWakeWord::loop() { switch (this->state_) { case State::STARTING: - if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) { + if (!this->inference_task_.is_created() && !this->status_has_error()) { // Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, @@ -320,10 +316,8 @@ void MicroWakeWord::loop() { return; } - xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, - INFERENCE_TASK_PRIORITY, &this->inference_task_handle_); - - if (this->inference_task_handle_ == nullptr) { + if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error("task_start", 1000); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index ef440b5d37..e4c590a423 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/static_task.h" #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -59,6 +60,8 @@ class MicroWakeWord : public Component void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } void add_wake_word_model(WakeWordModel *model); @@ -93,6 +96,8 @@ class MicroWakeWord : public Component bool stop_after_detection_; + bool task_stack_in_psram_{false}; + uint8_t features_step_size_; // Audio frontend handles generating spectrogram features @@ -105,8 +110,9 @@ class MicroWakeWord : public Component // Used to send messages about the models' states to the main loop QueueHandle_t detection_queue_; + StaticTask inference_task_; + static void inference_task(void *params); - TaskHandle_t inference_task_handle_{nullptr}; /// @brief Suspends the inference task void suspend_task_(); diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index c051c8dd57..cd060c176e 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,3 +1,6 @@ +psram: + mode: quad + i2s_audio: i2s_lrclk_pin: GPIO18 i2s_bclk_pin: GPIO19 @@ -12,6 +15,7 @@ microphone: micro_wake_word: microphone: echo_microphone + task_stack_in_psram: true on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: From 892e116680a0c8063e7d1ead63b74ed7748c89f9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 12:42:49 -0400 Subject: [PATCH 127/282] [router] Add a router speaker component to runtime choose output speaker (#16592) --- CODEOWNERS | 1 + esphome/components/router/__init__.py | 0 esphome/components/router/speaker/__init__.py | 123 +++++++++ .../router/speaker/router_speaker.cpp | 236 ++++++++++++++++++ .../router/speaker/router_speaker.h | 92 +++++++ tests/components/router/common.yaml | 44 ++++ tests/components/router/test.esp32-idf.yaml | 7 + 7 files changed, 503 insertions(+) create mode 100644 esphome/components/router/__init__.py create mode 100644 esphome/components/router/speaker/__init__.py create mode 100644 esphome/components/router/speaker/router_speaker.cpp create mode 100644 esphome/components/router/speaker/router_speaker.h create mode 100644 tests/components/router/common.yaml create mode 100644 tests/components/router/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f8cdfdc6c6..3c3e502058 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt +esphome/components/router/speaker/* @kahrendt esphome/components/rp2040/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan diff --git a/esphome/components/router/__init__.py b/esphome/components/router/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py new file mode 100644 index 0000000000..2b2dc56433 --- /dev/null +++ b/esphome/components/router/speaker/__init__.py @@ -0,0 +1,123 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import audio, speaker +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_OUTPUT_SPEAKER, + CONF_SAMPLE_RATE, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@kahrendt"] + +CONF_OUTPUT_SPEAKERS = "output_speakers" +CONF_TARGET_SPEAKER = "target_speaker" + +router_ns = cg.esphome_ns.namespace("router") +Router = router_ns.class_("Router", cg.Component, speaker.Speaker) +SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) + +SpeakerPtr = speaker.Speaker.operator("ptr") + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + # Lock the router's stream limits to the user-declared format. Limits are set + # at CONFIG_SCHEMA time so they're visible to other components' FINAL_VALIDATE + # (which has no guaranteed ordering vs. ours). + audio.set_stream_limits( + min_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + min_channels=config[CONF_NUM_CHANNELS], + max_channels=config[CONF_NUM_CHANNELS], + min_sample_rate=config[CONF_SAMPLE_RATE], + max_sample_rate=config[CONF_SAMPLE_RATE], + )(config) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Router), + cv.Required(CONF_OUTPUT_SPEAKERS): cv.All( + cv.ensure_list(cv.use_id(speaker.Speaker)), + cv.Length(min=2, max=8), + ), + # All outputs must agree on a single format so the producer can keep + # streaming through a switch without reconfiguring. These are required + # rather than inherited because downstream components (e.g. mixer) + # read them from the router's declaration during FINAL_VALIDATE, + # which can't depend on our FINAL_VALIDATE running first. + cv.Required(CONF_BITS_PER_SAMPLE): cv.int_range(8, 32), + cv.Required(CONF_NUM_CHANNELS): cv.int_range(1, 2), + cv.Required(CONF_SAMPLE_RATE): cv.int_range(8000, 96000), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _set_stream_limits, +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Validate every configured output speaker can accept the router's format. + # Switching to an output that can't reproduce the format the producer is + # already sending would otherwise fail silently at runtime. + for spk_id in config[CONF_OUTPUT_SPEAKERS]: + proxy = {**config, CONF_OUTPUT_SPEAKER: spk_id} + audio.final_validate_audio_schema( + "router", + audio_device=CONF_OUTPUT_SPEAKER, + bits_per_sample=config[CONF_BITS_PER_SAMPLE], + channels=config[CONF_NUM_CHANNELS], + sample_rate=config[CONF_SAMPLE_RATE], + )(proxy) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # The first configured output is the default active output on boot. + speakers = config[CONF_OUTPUT_SPEAKERS] + cg.add(var.set_output_count(len(speakers))) + for spk_id in speakers: + spk = await cg.get_variable(spk_id) + cg.add(var.add_output(spk)) + + +@automation.register_action( + "router.speaker.switch_output", + SwitchOutputAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(Router), + cv.Required(CONF_TARGET_SPEAKER): cv.templatable( + cv.use_id(speaker.Speaker) + ), + } + ), + synchronous=True, +) +async def switch_output_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + target = config[CONF_TARGET_SPEAKER] + if not isinstance(target, core.Lambda): + target = await cg.get_variable(target) + template_ = await cg.templatable(target, args, SpeakerPtr) + cg.add(var.set_target(template_)) + return var diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp new file mode 100644 index 0000000000..f4bf7420ab --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -0,0 +1,236 @@ +#include "router_speaker.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "esp_timer.h" + +#include + +namespace esphome::router { + +static const char *const TAG = "router.speaker"; + +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; + if (current > 0) { + uint32_t new_value; + do { + subtracted = std::min(amount, current); + new_value = current - subtracted; + } while (!var.compare_exchange_weak(current, new_value, std::memory_order_release, std::memory_order_acquire)); + } + return subtracted; +} + +void Router::setup() { + // Register a callback on every configured output. Each lambda captures its own + // index and only forwards when that output is the active one. This is required + // because CallbackManager has no remove() API. + for (size_t i = 0; i < this->outputs_.size(); i++) { + this->outputs_[i]->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp_us) { + // Always suppress the draining previous output during a switch, even if it's + // also the reselected active output (switching back to the bus holder). + // loop() fires one synthetic credit for its in-flight frames instead. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) == static_cast(i)) { + return; + } + if (this->active_output_idx_.load(std::memory_order_relaxed) != static_cast(i)) { + return; + } + atomic_subtract_clamped(this->frames_in_pipeline_, frames); + this->audio_output_callback_.call(frames, timestamp_us); + }); + } +} + +void Router::loop() { + speaker::Speaker *active = this->get_active_output(); + + // Mid-switch: the new output's start() is deferred until the previous output + // fully releases shared hardware (e.g. a single i2s_audio bus driving two + // speakers). Starting earlier produces "Parent bus is busy" retries. The + // synthetic-credit callback is also deferred until prev is fully stopped, so + // that once its task has drained no natural callbacks can race ours. + const int8_t pending_prev_idx = this->pending_start_prev_idx_.load(std::memory_order_relaxed); + if (pending_prev_idx >= 0) { + speaker::Speaker *prev = this->outputs_[pending_prev_idx]; + if (prev->is_stopped()) { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + + // Credit any frames left in prev's ring buffer / DMA so producer frame + // accounting (SpeakerSourceMediaPlayer pending_frames, sendspin/AEC + // clocks) clears cleanly. The leftover audio is intentionally dropped and + // the producer is told it played "now", giving a clean discontinuity that + // keeps frame accounting consistent across the switch. + const uint32_t in_flight = this->frames_in_pipeline_.exchange(0, std::memory_order_acq_rel); + if (in_flight > 0) { + this->audio_output_callback_.call(in_flight, esp_timer_get_time()); + } + + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + active->start(); + } + return; + } + + // Mirror the active output's running/stopped state into our own state_ so that + // is_running() / is_stopped() stay accurate from the producer's perspective. + // Also catch the active output self-stopping (e.g. i2s_audio silence timeout): + // without this, our state_ would stay RUNNING forever and the next play() would + // skip start(). The output retains its own volume/mute across a restart (and we + // forward those live regardless), but stream info arrives via the non-virtual + // 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()) { + this->state_ = speaker::STATE_STOPPED; + } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } +} + +void Router::dump_config() { + ESP_LOGCONFIG(TAG, + "Router Speaker:\n" + " Outputs: %u", + static_cast(this->outputs_.size())); +} + +size_t Router::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + speaker::Speaker *active = this->get_active_output(); + + // Drop frames during a mid-switch until the old output releases shared hardware; + // forwarding now would trigger the new output's play()-side auto-start while + // the bus is still busy. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + vTaskDelay(ticks_to_wait); + return 0; + } + + // Producers (e.g. mixer) set stream info on us and then drive play() from a + // task without ever calling our start(). i2s_audio's play() auto-starts the + // underlying driver, so we must push our cached stream info to the active + // output before that auto-start, or it locks to its default (16k mono). + if (this->state_ == speaker::STATE_STOPPED) { + this->start(); + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t written = active->play(data, length, ticks_to_wait); + if (written > 0) { + const uint32_t frames = this->audio_stream_info_.bytes_to_frames(written); + this->frames_in_pipeline_.fetch_add(frames, std::memory_order_release); + } + return written; +} + +void Router::start() { + this->frames_in_pipeline_.store(0, std::memory_order_release); + this->apply_cached_state_to_active_(); + this->state_ = speaker::STATE_STARTING; + this->get_active_output()->start(); +} + +void Router::stop() { + // Cancel any pending mid-switch start; the producer wants us stopped. + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->stop(); +} + +void Router::finish() { + this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed); + this->state_ = speaker::STATE_STOPPING; + this->get_active_output()->finish(); +} + +bool Router::has_buffered_data() const { return this->get_active_output()->has_buffered_data(); } + +void Router::set_pause_state(bool pause_state) { + this->cached_pause_ = pause_state; + this->get_active_output()->set_pause_state(pause_state); +} + +void Router::set_volume(float volume) { + this->volume_ = volume; + this->get_active_output()->set_volume(volume); +} + +void Router::set_mute_state(bool mute_state) { + this->mute_state_ = mute_state; + this->get_active_output()->set_mute_state(mute_state); +} + +bool Router::switch_to_output(speaker::Speaker *target) { + if (target == nullptr) { + return false; + } + + int8_t new_idx = -1; + for (size_t i = 0; i < this->outputs_.size(); i++) { + if (this->outputs_[i] == target) { + new_idx = static_cast(i); + break; + } + } + if (new_idx < 0) { + ESP_LOGW(TAG, "Switch target is not a configured output"); + return false; + } + if (new_idx == this->active_output_idx_.load(std::memory_order_relaxed)) { + return true; + } + + // A switch is already in flight: pending_start_prev_idx_ is still releasing the + // shared bus and the current active output's start() is still deferred (it never + // started). Just redirect which output we start once the bus frees. Leave the bus + // holder (pending_start_prev_idx_), the in-flight frame counter (loop() still owes one + // synthetic credit for the bus holder's in-flight frames), and state_ alone, and + // don't stop the current active output, which never started. + if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) { + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + return true; + } + + const bool was_active = (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING); + const int8_t old_idx = this->active_output_idx_.load(std::memory_order_relaxed); + + if (was_active) { + this->outputs_[old_idx]->stop(); + } + + this->active_output_idx_.store(new_idx, std::memory_order_relaxed); + + if (was_active) { + // Defer start and the synthetic-credit callback until the old output's + // task is fully stopped; loop() handles both. Firing the synthetic credit + // here would race the old task's still-in-flight natural callbacks, + // dispatching audio_output_callback_ concurrently from two threads, which + // some consumers (e.g. sendspin's progress sync) aren't reentrant-safe for. + // STATE_STOPPING keeps producers from observing a transient stopped state + // and lets our play() short-circuit so the new output's play() doesn't + // auto-start it while the shared bus is still being released. + this->state_ = speaker::STATE_STOPPING; + this->pending_start_prev_idx_.store(old_idx, std::memory_order_relaxed); + } else { + this->frames_in_pipeline_.store(0, std::memory_order_release); + } + return true; +} + +void Router::apply_cached_state_to_active_() { + speaker::Speaker *active = this->get_active_output(); + active->set_audio_stream_info(this->audio_stream_info_); + active->set_volume(this->volume_); + active->set_mute_state(this->mute_state_); + active->set_pause_state(this->cached_pause_); +} + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h new file mode 100644 index 0000000000..13b58a1c72 --- /dev/null +++ b/esphome/components/router/speaker/router_speaker.h @@ -0,0 +1,92 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::router { + +class Router : public Component, public speaker::Speaker { + public: + float get_setup_priority() const override { return setup_priority::DATA; } + + void setup() override; + void loop() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length) override { return this->play(data, length, 0); } + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + void start() override; + void stop() override; + void finish() override; + + bool has_buffered_data() const override; + + void set_pause_state(bool pause_state) override; + bool get_pause_state() const override { return this->cached_pause_; } + + void set_volume(float volume) override; + float get_volume() override { return this->volume_; } + + void set_mute_state(bool mute_state) override; + bool get_mute_state() override { return this->mute_state_; } + + // Allocate the output list to its final size. Must be called before add_output(). + void set_output_count(size_t count) { this->outputs_.init(count); } + void add_output(speaker::Speaker *spk) { this->outputs_.push_back(spk); } + + /// Switch the active output to the given speaker. Must be one of the configured outputs. + /// Returns false if `target` is not in the output list. + bool switch_to_output(speaker::Speaker *target); + + // Always valid: active_output_idx_ stays within [0, outputs_.size()) and at least + // two outputs are required (validated in Python), so this never returns null. + speaker::Speaker *get_active_output() const { + return this->outputs_[this->active_output_idx_.load(std::memory_order_relaxed)]; + } + + protected: + // Frames written to the active output but not yet played: incremented in play() and decremented + // (clamped at zero) by the active output's audio_output_callback. Mirrors mixer_speaker's + // frames_in_pipeline_. + std::atomic frames_in_pipeline_{0}; + + bool cached_pause_{false}; + + void apply_cached_state_to_active_(); + + // Index of the previously-active output we're waiting on to fully stop before + // starting the new one. -1 means no pending start. Set by switch_to_output() + // when switching mid-playback; cleared by loop() once the old output reports + // is_stopped(). Required because shared-bus drivers (e.g. two i2s_audio + // speakers on one i2s_bus) reject start() until the previous user releases. + std::atomic pending_start_prev_idx_{-1}; + + private: + FixedVector outputs_; + // Index into outputs_, always within [0, outputs_.size()). Defaults to the first + // configured output; updated by switch_to_output(). + std::atomic active_output_idx_{0}; +}; + +template class SwitchOutputAction : public Action { + public: + explicit SwitchOutputAction(Router *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(speaker::Speaker *, target) + void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } + + protected: + Router *parent_; +}; + +} // namespace esphome::router + +#endif // USE_ESP32 diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml new file mode 100644 index 0000000000..360c6daaee --- /dev/null +++ b/tests/components/router/common.yaml @@ -0,0 +1,44 @@ +esphome: + on_boot: + then: + - router.speaker.switch_output: + id: router_id + target_speaker: speaker_b_id + # id omitted: auto-resolved since there's a single router instance + - router.speaker.switch_output: + target_speaker: !lambda return id(speaker_a_id); + +i2s_audio: + - id: i2s_a + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} + - id: i2s_b + +speaker: + - platform: i2s_audio + id: speaker_a_id + i2s_audio_id: i2s_a + dac_type: external + i2s_dout_pin: ${a_dout_pin} + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + - platform: i2s_audio + id: speaker_b_id + i2s_audio_id: i2s_b + dac_type: external + i2s_dout_pin: ${b_dout_pin} + spdif_mode: true + use_apll: true + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + i2s_mode: primary + - platform: router + id: router_id + output_speakers: + - speaker_a_id + - speaker_b_id + sample_rate: 48000 + bits_per_sample: 16 + num_channels: 2 diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml new file mode 100644 index 0000000000..241a9a8903 --- /dev/null +++ b/tests/components/router/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + a_lrclk_pin: GPIO4 + a_bclk_pin: GPIO5 + a_dout_pin: GPIO14 + b_dout_pin: GPIO19 + +<<: !include common.yaml From dcc30f865105587c26a85fc2cf29a4212726c86e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 25 May 2026 15:39:54 -0400 Subject: [PATCH 128/282] [router] Share a single I2S bus in test (#16637) --- tests/components/router/common.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index 360c6daaee..f1239de3cb 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -9,15 +9,12 @@ esphome: target_speaker: !lambda return id(speaker_a_id); i2s_audio: - - id: i2s_a - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - - id: i2s_b + i2s_lrclk_pin: ${a_lrclk_pin} + i2s_bclk_pin: ${a_bclk_pin} speaker: - platform: i2s_audio id: speaker_a_id - i2s_audio_id: i2s_a dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -25,7 +22,6 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id - i2s_audio_id: i2s_b dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true From 0b780f1fd29a70279c41e6e376d9a19f8f6f7e58 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 26 May 2026 06:21:15 +0930 Subject: [PATCH 129/282] [time][homeassistant] Fix timezone handling (#16583) --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/client.py | 1 + .../components/homeassistant/time/__init__.py | 4 +- esphome/components/time/__init__.py | 56 ++- esphome/core/defines.h | 1 + tests/component_tests/time/__init__.py | 1 + tests/component_tests/time/test_init.py | 369 ++++++++++++++++++ 7 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/time/__init__.py create mode 100644 tests/component_tests/time/test_init.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f2bf3752fa..c880e036cb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1169,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); -#ifdef USE_TIME_TIMEZONE +#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) if (!value.timezone.empty()) { // Check if the sender provided pre-parsed timezone data. // If std_offset is non-zero or DST rules are present, the parsed data was populated. diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index d6150fbd29..7fba091730 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -101,6 +101,7 @@ async def async_run_logs( client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, ) # Try platform-specific stacktrace handler first, fall back to generic diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 62cb96a25a..05ca86a26e 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIMEZONE from .. import homeassistant_ns @@ -21,3 +21,5 @@ async def to_code(config): await time_.register_time(var, config) await cg.register_component(var, config) cg.add_define("USE_HOMEASSISTANT_TIME") + if CONF_TIMEZONE not in config: + cg.add_define("USE_HOMEASSISTANT_TIMEZONE") diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 29bb01b499..8839a988a1 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -30,13 +30,21 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) @@ -92,20 +100,34 @@ def _extract_tz_string(tzfile: bytes) -> str: raise -def detect_tz() -> str: +def detect_tz() -> str | None: + if CORE.target_platform not in { + PLATFORM_ESP8266, + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + PLATFORM_HOST, + }: + return None + # Avoids duplicate logger messages when multiple time components are configured + if cached := CORE.data.setdefault(DOMAIN, {}).get(CONF_TIMEZONE): + return cached iana_key = tzlocal.get_localzone_name() if iana_key is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) - _LOGGER.info("Detected timezone '%s'", iana_key) tzfile = _load_tzdata(iana_key) if tzfile is None: - raise cv.Invalid( + raise EsphomeError( "Could not automatically determine timezone, please set timezone manually." ) ret = _extract_tz_string(tzfile) + _LOGGER.info("Detected timezone '%s'", iana_key) _LOGGER.debug(" -> TZ string %s", ret) + CORE.data.setdefault(DOMAIN, {})[CONF_TIMEZONE] = ret return ret @@ -312,16 +334,7 @@ def validate_tz(value: str) -> str: TIME_SCHEMA = cv.Schema( { - cv.SplitDefault( - CONF_TIMEZONE, - esp8266=detect_tz, - esp32=detect_tz, - rp2040=detect_tz, - bk72xx=detect_tz, - rtl87xx=detect_tz, - ln882x=detect_tz, - host=detect_tz, - ): cv.All( + cv.Optional(CONF_TIMEZONE): cv.All( cv.only_with_framework(["arduino", "esp-idf", "host"]), validate_tz, ), @@ -384,7 +397,11 @@ def _emit_parsed_timezone_fields(parsed): async def setup_time_core_(time_var, config): - if timezone := config.get(CONF_TIMEZONE): + timezone = config.get(CONF_TIMEZONE) + # an empty timezone is treated as disabling timezones completely as before + if timezone is None: + timezone = detect_tz() + if timezone: cg.add_define("USE_TIME_TIMEZONE") if CORE.is_host: @@ -392,8 +409,11 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) + try: + parsed = parse_posix_tz_python(timezone) + _emit_parsed_timezone_fields(parsed) + except ValueError as e: + raise EsphomeError(f"Invalid timezone: {timezone}") from e for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3cb92616bb..0229bc14fa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME +#define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE diff --git a/tests/component_tests/time/__init__.py b/tests/component_tests/time/__init__.py new file mode 100644 index 0000000000..dc24f4e532 --- /dev/null +++ b/tests/component_tests/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/component_tests/time/test_init.py b/tests/component_tests/time/test_init.py new file mode 100644 index 0000000000..44469cfe28 --- /dev/null +++ b/tests/component_tests/time/test_init.py @@ -0,0 +1,369 @@ +"""Tests for time component – ha-timezone branch changes. + +Covers: +- detect_tz() platform guard (returns None for unsupported platforms) +- detect_tz() result caching (avoids duplicate log messages) +- detect_tz() error paths (tzlocal None, tzdata missing) +- validate_tz() accepts/rejects POSIX timezone strings and IANA keys +- TIME_SCHEMA: timezone is now truly optional (was SplitDefault) +- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff + CONF_TIMEZONE is absent from the config +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TIMEZONE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Platform, + PlatformFramework, +) +from esphome.core import CORE, EsphomeError +from tests.component_tests.types import SetCoreConfigCallable + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line. +# The binary content is not validated at this level – what matters is that +# _extract_tz_string() picks up the last-but-one newline-terminated line. +_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n" + + +def _set_platform(platform: Platform) -> None: + """Set CORE.data so that CORE.target_platform returns *platform*.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + +# --------------------------------------------------------------------------- +# detect_tz – platform guard +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.NRF52_ZEPHYR, + ], +) +def test_detect_tz_returns_none_for_unsupported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must return None for platforms that do not support TZ auto-detection.""" + set_core_config(platform_framework) + result = detect_tz() + assert result is None + + +@pytest.mark.parametrize( + "platform_framework", + [ + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.RP2040_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.HOST_NATIVE, + ], +) +def test_detect_tz_calls_tzlocal_for_supported_platform( + platform_framework: PlatformFramework, + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must call tzlocal for every supported platform.""" + set_core_config(platform_framework) + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + assert result is not None + assert isinstance(result, str) + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# detect_tz – caching +# --------------------------------------------------------------------------- + + +def test_detect_tz_caches_result( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """detect_tz() must cache the TZ string after the first call so that + subsequent invocations (e.g. when multiple time platforms are configured) + skip tzlocal and avoid duplicate INFO messages.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="America/New_York", + ) as mock_tz, + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ) as mock_load, + ): + first = detect_tz() + second = detect_tz() + + assert first == second + # tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls + mock_tz.assert_called_once() + mock_load.assert_called_once() + + +def test_detect_tz_cache_stored_in_core_data( + set_core_config: SetCoreConfigCallable, +) -> None: + """The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE].""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Europe/London", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ), + ): + result = detect_tz() + + assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result + + +def test_detect_tz_returns_pre_seeded_cache( + set_core_config: SetCoreConfigCallable, +) -> None: + """If CORE.data already has a cached TZ string, detect_tz() must return it + without calling tzlocal at all.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"} + + with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz: + result = detect_tz() + + assert result == "CET-1CEST,M3.5.0,M10.5.0/3" + mock_tz.assert_not_called() + + +# --------------------------------------------------------------------------- +# detect_tz – error paths +# --------------------------------------------------------------------------- + + +def test_detect_tz_raises_when_tzlocal_returns_none( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when the local timezone cannot be determined.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +def test_detect_tz_raises_when_tzdata_not_found( + set_core_config: SetCoreConfigCallable, +) -> None: + """detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with ( + mock.patch( + "esphome.components.time.tzlocal.get_localzone_name", + return_value="Antarctica/Troll", + ), + mock.patch( + "esphome.components.time._load_tzdata", + return_value=None, + ), + pytest.raises(EsphomeError, match="Could not automatically determine timezone"), + ): + detect_tz() + + +# --------------------------------------------------------------------------- +# validate_tz +# --------------------------------------------------------------------------- + + +def test_validate_tz_accepts_valid_posix_string() -> None: + """validate_tz() must accept a syntactically valid POSIX TZ string.""" + result = validate_tz("UTC0") + assert result == "UTC0" + + +def test_validate_tz_accepts_posix_string_with_dst() -> None: + """validate_tz() must accept a full POSIX TZ string with DST rules.""" + tz = "EST5EDT,M3.2.0,M11.1.0" + result = validate_tz(tz) + assert result == tz + + +def test_validate_tz_accepts_iana_key_and_converts() -> None: + """validate_tz() must accept an IANA timezone key and return the POSIX string.""" + with mock.patch( + "esphome.components.time._load_tzdata", + return_value=_FAKE_TZFILE, + ): + result = validate_tz("America/New_York") + + # Should have been converted from IANA to POSIX via _extract_tz_string + assert result == "EST5EDT,M3.2.0,M11.1.0" + + +def test_validate_tz_rejects_invalid_posix_string() -> None: + """validate_tz() must raise cv.Invalid for a malformed POSIX TZ string.""" + with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"): + validate_tz("NOTAVALIDTZ!!!") + + +def test_validate_tz_accepts_empty_string() -> None: + """An empty string is accepted by validate_tz() and signals 'disable timezone'.""" + result = validate_tz("") + assert result == "" + + +# --------------------------------------------------------------------------- +# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault) +# --------------------------------------------------------------------------- + + +def test_time_schema_timezone_is_optional( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept a config with no timezone key on a supported platform.""" + set_core_config(PlatformFramework.ESP32_IDF) + # Should not raise + config = TIME_SCHEMA({}) + assert CONF_TIMEZONE not in config + + +def test_time_schema_explicit_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + assert config[CONF_TIMEZONE] == "UTC0" + + +def test_time_schema_explicit_empty_timezone_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = TIME_SCHEMA({CONF_TIMEZONE: ""}) + assert config[CONF_TIMEZONE] == "" + + +def test_time_schema_timezone_rejected_on_zephyr( + set_core_config: SetCoreConfigCallable, +) -> None: + """TIME_SCHEMA must reject a timezone value on Zephyr with the framework error. + + The platform check (cv.only_with_framework) must run BEFORE validate_tz so + that users receive an actionable "unsupported framework" message rather than a + confusing TZ-parsing error. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "UTC0"}) + + +def test_time_schema_invalid_tz_on_zephyr_gives_framework_error( + set_core_config: SetCoreConfigCallable, +) -> None: + """Even a syntactically invalid TZ string must produce the framework error on Zephyr. + + This specifically tests that cv.only_with_framework is evaluated before + validate_tz: if the order were reversed, an invalid POSIX string would + generate a misleading TZ-parsing error instead. + """ + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="only available with framework"): + TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"}) + + +# --------------------------------------------------------------------------- +# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_ha_cg(): + """Mock codegen functions used by homeassistant/time to_code.""" + with ( + mock.patch( + "esphome.components.homeassistant.time.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.homeassistant.time.cg.add_define", + ) as mock_add_define, + mock.patch( + "esphome.components.homeassistant.time.cg.register_component", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.homeassistant.time.time_.register_time", + new_callable=mock.AsyncMock, + ), + ): + yield mock_add_define + + +@pytest.mark.asyncio +async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is absent from the config, to_code() must call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock()}) + + mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE") + + +@pytest.mark.asyncio +async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None: + """When CONF_TIMEZONE is present in the config, to_code() must NOT call + cg.add_define('USE_HOMEASSISTANT_TIMEZONE').""" + from esphome.components.homeassistant.time import to_code + + await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"}) + + define_calls = [call.args[0] for call in mock_ha_cg.call_args_list] + assert "USE_HOMEASSISTANT_TIME" in define_calls + assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls From fc0a4e22011fc4e852dc05b1433fbbc398434db7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:07:35 -0400 Subject: [PATCH 130/282] [espidf] Support github:// and https://github.com/.../.git framework sources (#16639) --- esphome/espidf/framework.py | 115 +++++++++++++--- esphome/git.py | 5 +- tests/unit_tests/test_espidf_framework.py | 156 ++++++++++++++++++++++ 3 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 tests/unit_tests/test_espidf_framework.py diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 079c97cc98..fb53066edb 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -8,6 +8,7 @@ import logging import os from pathlib import Path import platform +import re import shutil import subprocess import sys @@ -784,6 +785,77 @@ def download_from_mirrors( return None +_GITHUB_SHORTHAND_RE = re.compile( + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) +_GITHUB_HTTPS_RE = re.compile( + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" +) + + +def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: + """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or + ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + if m := _GITHUB_SHORTHAND_RE.match(source_url): + owner, repo, ref = m.group(1), m.group(2), m.group(3) + # Tolerate a trailing ".git" on the shorthand repo so the + # github://owner/repo.git form doesn't silently become repo.git.git. + repo = repo.removesuffix(".git") + return f"https://github.com/{owner}/{repo}.git", ref + if m := _GITHUB_HTTPS_RE.match(source_url): + return m.group(1), m.group(2) + return None + + +def _clone_idf_with_submodules( + framework_path: Path, git_url: str, ref: str | None +) -> None: + """Shallow-clone ESP-IDF with submodules into ``framework_path``. + + GitHub's archive zip strips submodules, so vendored components + (mbedtls, openthread, esptool, ...) come down empty and CMake fails. + + Uses clone + ``fetch FETCH_HEAD`` + ``reset --hard`` instead of + ``--branch``: ``--branch`` only accepts branch or tag names, but a + user can also point at a commit SHA. The fetch-then-reset pattern + handles branches, tags, and SHAs uniformly (mirrors the approach in + ``esphome.git.clone_or_update``). + """ + from esphome.git import run_git_command + + _LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "") + run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + if ref: + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=framework_path, + ) + run_git_command( + ["git", "reset", "--hard", "FETCH_HEAD"], + git_dir=framework_path, + ) + run_git_command( + [ + "git", + "submodule", + "update", + "--init", + "--recursive", + "--depth=1", + ], + git_dir=framework_path, + ) + + # Sanity-check the resulting tree. run_git_command only raises when + # stderr is non-empty, so a clone that silently produces no working + # tree would otherwise be marked extracted and stuck until + # ``esphome clean``. + if not (framework_path / "tools" / "idf_tools.py").is_file(): + raise RuntimeError( + f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}" + ) + + def _write_idf_version_txt(framework_path: Path, version: str) -> None: """Write /version.txt if missing. @@ -939,27 +1011,34 @@ def _check_esphome_idf_framework_install( if install: rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading ESP-IDF %s framework ...", version) + git_source = _parse_git_source(source_url) if source_url else None + if git_source is not None: + git_url, ref = git_source + _clone_idf_with_submodules(framework_path, git_url, ref) + else: + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading ESP-IDF %s framework ...", version) - # Create substitutions for the URLs - substitutions = {"VERSION": version} - try: - ver = Version.parse(version) - substitutions["MAJOR"] = str(ver.major) - substitutions["MINOR"] = str(ver.minor) - substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra - except ValueError: - pass + # Create substitutions for the URLs + substitutions = {"VERSION": version} + try: + ver = Version.parse(version) + substitutions["MAJOR"] = str(ver.major) + substitutions["MINOR"] = str(ver.minor) + substitutions["PATCH"] = str(ver.patch) + substitutions["EXTRA"] = ver.extra + except ValueError: + pass - mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS - download_from_mirrors(mirrors, substitutions, tmp.file) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) - _LOGGER.info("Extracting ESP-IDF %s framework ...", version) - archive_extract_all(tmp.file, framework_path, progress_header="Extracting") - extracted_marker.touch() + _LOGGER.info("Extracting ESP-IDF %s framework ...", version) + archive_extract_all( + tmp.file, framework_path, progress_header="Extracting" + ) + extracted_marker.touch() # Idempotent post-extract patch: written every invocation so a build # dir extracted before this fix gets the file too, without forcing a diff --git a/esphome/git.py b/esphome/git.py index 0106f24845..f36bd559ef 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -72,8 +72,9 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: ) except FileNotFoundError as err: raise GitNotInstalledError( - "git is not installed but required for external_components.\n" - "Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" + "git is not installed. See " + "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git " + "for installation instructions." ) from err if ret.returncode != 0 and ret.stderr: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py new file mode 100644 index 0000000000..9f4e4fcca8 --- /dev/null +++ b/tests/unit_tests/test_espidf_framework.py @@ -0,0 +1,156 @@ +"""Tests for esphome.espidf.framework helpers.""" + +# pylint: disable=protected-access + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + # github:// shorthand + ( + "github://espressif/esp-idf", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "github://espressif/esp-idf@release/v6.0", + ("https://github.com/espressif/esp-idf.git", "release/v6.0"), + ), + # explicit https://github.com/...git URL + ( + "https://github.com/espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "https://github.com/espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ( + "https://github.com/espressif/esp-idf.git@v6.0.1", + ("https://github.com/espressif/esp-idf.git", "v6.0.1"), + ), + # Tolerate a trailing ".git" on the shorthand so the user doesn't + # silently end up with a doubled "...esp-idf.git.git" URL. + ( + "github://espressif/esp-idf.git", + ("https://github.com/espressif/esp-idf.git", None), + ), + ( + "github://espressif/esp-idf.git@master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), + ], +) +def test_parse_git_source_recognized( + source: str, expected: tuple[str, str | None] +) -> None: + assert _parse_git_source(source) == expected + + +@pytest.mark.parametrize( + "source", + [ + # archive URLs fall through to the existing download path + "https://github.com/espressif/esp-idf/archive/refs/heads/master.zip", + "https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip", + "https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz", + # SSH and other git protocols are intentionally rejected — match + # external_components, which only recognizes github:// + structured + # dicts for these. + "git@github.com:espressif/esp-idf.git", + "ssh://git@github.com/espressif/esp-idf.git", + "git://github.com/espressif/esp-idf.git", + # non-GitHub .git URLs are intentionally rejected for the same reason + "https://gitlab.com/foo/bar.git", + "https://github.example.com/foo/bar.git", + ], +) +def test_parse_git_source_rejected(source: str) -> None: + assert _parse_git_source(source) is None + + +def _make_idf_tree(framework_path: Path) -> None: + """Create the minimum tree _clone_idf_with_submodules sanity-checks for.""" + (framework_path / "tools").mkdir(parents=True) + (framework_path / "tools" / "idf_tools.py").write_text("# stub\n") + + +def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, "https://github.com/espressif/esp-idf.git", None + ) + + # No ref -> just clone + submodule update, no fetch/reset. + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + assert calls[0] == [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/espressif/esp-idf.git", + str(framework_path), + ] + assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] + assert not any(c[1] == "fetch" for c in calls) + assert not any(c[1] == "reset" for c in calls) + + +def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + _make_idf_tree(framework_path) + + with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock: + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + "master", + ) + + calls = [c.args[0] for c in run_git_command_mock.call_args_list] + # clone, fetch ref, reset hard, submodule update + assert calls[0][:2] == ["git", "clone"] + assert calls[1] == [ + "git", + "fetch", + "--depth=1", + "--", + "origin", + "master", + ] + assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] + assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + + +def test_clone_idf_with_submodules_raises_when_tree_missing( + tmp_path: Path, +) -> None: + framework_path = tmp_path / "idf" + framework_path.mkdir() + # Deliberately do NOT call _make_idf_tree — simulate a clone that + # returned 0 but produced no tools/idf_tools.py. + + with ( + patch("esphome.git.run_git_command", return_value=""), + pytest.raises(RuntimeError, match="no usable ESP-IDF tree"), + ): + _clone_idf_with_submodules( + framework_path, + "https://github.com/espressif/esp-idf.git", + None, + ) From 61e8830a3ce38a67647d2b17b863049b296e7aaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 25 May 2026 17:40:38 -0400 Subject: [PATCH 131/282] [espidf] Keep cmake output filter working when IDF writes raw bytes (#16642) --- esphome/espidf/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index da3f77cdd3..857d16c674 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -164,6 +164,12 @@ def main() -> int: self._line_buffer = "" def __getattr__(self, name: str): + # Hide ``buffer`` so consumers that use either + # ``getattr(stream, 'buffer', None)`` or + # ``hasattr(stream, 'buffer')`` see this as a text-only stream + # and skip writing raw bytes (which would bypass the filter). + if name == "buffer": + raise AttributeError(name) return getattr(self._stream, name) def isatty(self) -> bool: From a257edba626ab1455f882f58a8a3f5787745a231 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 25 May 2026 23:46:33 +0200 Subject: [PATCH 132/282] [mitsubishi_cn105] Add basic swing support (#15653) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/mitsubishi_cn105/climate.py | 12 +- .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 90 ++++++++++ .../mitsubishi_cn105_climate.h | 5 + .../mitsubishi_cn105_climate_tests.cpp | 165 ++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 11 ++ tests/components/mitsubishi_cn105/common.yaml | 3 + 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index cc44494d89..522b9218fc 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,8 +1,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart +from esphome.components.climate import validate_climate_swing_mode import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_ID, + CONF_SUPPORTED_SWING_MODES, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) from esphome.core import ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType @@ -43,6 +49,9 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, } ) ) @@ -63,6 +72,7 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) cg.add( var.set_current_temperature_min_interval( config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index dbeb43068e..742d8e18a9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/uart/uart.h" #include "esphome/core/finite_set_mask.h" diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 67a561397a..afffe7ea5e 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.add_supported_fan_mode(p.second); } + traits.set_supported_swing_modes(this->supported_swing_modes_); + traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); @@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { this->hp_.set_fan_mode(*fan_mode); } + if (const auto swing_mode = call.get_swing_mode()) { + auto vane = this->last_non_swing_vane_mode_; + auto wide = this->last_non_swing_wide_vane_mode_; + + switch (*swing_mode) { + case climate::CLIMATE_SWING_BOTH: + vane = MitsubishiCN105::VaneMode::SWING; + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_VERTICAL: + vane = MitsubishiCN105::VaneMode::SWING; + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + wide = MitsubishiCN105::WideVaneMode::SWING; + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + this->hp_.set_vane_mode(vane); + } + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + this->hp_.set_wide_vane_mode(wide); + } + } + if (this->hp_.is_status_initialized()) { this->apply_values_(); } @@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() { ESP_LOGD(TAG, "Unable to map fan mode"); } + if (!this->supported_swing_modes_.empty()) { + bool vertical_swinging = false; + bool horizontal_swinging = false; + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) { + vertical_swinging = true; + } else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->last_non_swing_vane_mode_ = status.vane_mode; + } + } + + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) { + horizontal_swinging = true; + } else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) { + this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode; + } + } + + if (vertical_swinging && horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (vertical_swinging) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else if (horizontal_swinging) { + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + } + this->publish_state(); } +void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) { + this->supported_swing_modes_.clear(); + switch (mode) { + case climate::CLIMATE_SWING_VERTICAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + break; + + case climate::CLIMATE_SWING_BOTH: + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); + this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH); + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index e09158bfcf..c83a5519c1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + void set_supported_swing_mode(climate::ClimateSwingMode mode); + protected: void apply_values_(); MitsubishiCN105 hp_; + climate::ClimateSwingModeMask supported_swing_modes_{}; + MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; + MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; }; template diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp new file mode 100644 index 0000000000..36e0fc90b4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -0,0 +1,165 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF); + + EXPECT_FALSE(sut.traits().get_supports_swing_modes()); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL)); + EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH)); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + + sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2; + sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT; + + sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN; + + sut.apply_values_(); + + EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2); + EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT); + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) { + TestableMitsubishiCN105Climate sut; + + sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO; + sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING; + + sut.apply_values_(); + + EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 59b6203732..798f7283f6 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -44,6 +45,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { using MitsubishiCN105::State; using MitsubishiCN105::UpdateFlag; using MitsubishiCN105::state_; + using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; using MitsubishiCN105::use_temperature_encoding_b_; using MitsubishiCN105::set_wide_vane_high_bit_; @@ -58,4 +60,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } }; +class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { + public: + using MitsubishiCN105Climate::apply_values_; + using MitsubishiCN105Climate::last_non_swing_vane_mode_; + using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; + + MitsubishiCN105::Status &status() { return static_cast(this->hp_).status_; } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 4b64f51261..5b9c3aaaf6 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,9 @@ climate: id: ac name: "AC Test" uart_id: uart_bus + update_interval: 30s + current_temperature_min_interval: 120s + supported_swing_modes: BOTH esphome: on_boot: From 8645f3672d142417e5b3c0351e3946b9c2d7ebb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 18:11:40 -0500 Subject: [PATCH 133/282] [core] Enable additional zero-violation ruff lint families (#16645) --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d16bf2b625..94cd6d21b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,14 +113,22 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb "I", # isort + "ICN", # flake8-import-conventions + "LOG", # flake8-logging + "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "Q", # flake8-quotes "SIM", # flake8-simplify "RET", # flake8-ret + "T10", # flake8-debugger "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] ignore = [ From 97267105e1917a0195d51b0267dc9b1073a47d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:05:51 -0500 Subject: [PATCH 134/282] [core] Enable ruff EXE (flake8-executable) lint family (#16648) --- pyproject.toml | 1 + script/ci_helpers.py | 0 2 files changed, 1 insertion(+) mode change 100755 => 100644 script/ci_helpers.py diff --git a/pyproject.toml b/pyproject.toml index 94cd6d21b8..5de8775713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "E", # pycodestyle + "EXE", # flake8-executable "F", # pyflakes/autoflake "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings diff --git a/script/ci_helpers.py b/script/ci_helpers.py old mode 100755 new mode 100644 From 51722279312ab8c9e0475766592a0c39a581c2a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:01 -0500 Subject: [PATCH 135/282] [core] Enable ruff SLOT (flake8-slots) lint family (#16647) --- pyproject.toml | 1 + tests/unit_tests/test_yaml_util.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5de8775713..a48bae660f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PL", # pylint "Q", # flake8-quotes "SIM", # flake8-simplify + "SLOT", # flake8-slots "RET", # flake8-ret "T10", # flake8-debugger "UP", # pyupgrade diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index de70a5307d..d6fb5b81f2 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -907,7 +907,7 @@ def test_format_path_current_obj_without_location_falls_back_to_key(): """An ESPHomeDataBase current_obj with no esp_range falls back to the key's location.""" class _NoRange(ESPHomeDataBase, str): - pass + __slots__ = () obj = _NoRange.__new__(_NoRange, "value") str.__init__(obj) From f1839489dd6c7ec61020fb0fa7e7e1fc5c62bda9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:18 -0500 Subject: [PATCH 136/282] [core] Enable ruff ISC (flake8-implicit-str-concat) lint family (#16646) --- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a48bae660f..8916abd6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ select = [ "FURB", # refurb "I", # isort "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index bf672d0567..91aec91637 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1283,11 +1283,11 @@ class PackedBufferTypeInfo(TypeInfo): """Dump shows buffer info but not decoded values.""" return ( f'out.append(2, \' \').append_p(ESPHOME_PSTR("{self.name}")).append(": ");\n' - + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' - + f"append_uint(out, this->{self.field_name}_count_);\n" - + 'out.append_p(ESPHOME_PSTR(" values, "));\n' - + f"append_uint(out, this->{self.field_name}_length_);\n" - + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' + 'out.append_p(ESPHOME_PSTR("packed buffer ["));\n' + f"append_uint(out, this->{self.field_name}_count_);\n" + 'out.append_p(ESPHOME_PSTR(" values, "));\n' + f"append_uint(out, this->{self.field_name}_length_);\n" + 'out.append_p(ESPHOME_PSTR(" bytes]\\n"));' ) def dump(self, name: str) -> str: From bbc24ab5469ad15075ecd4ee52a60e6285bdd7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:06:34 -0500 Subject: [PATCH 137/282] [core] Enable ruff RSE (flake8-raise) lint family (#16649) --- esphome/components/external_components/__init__.py | 2 +- esphome/components/waveshare_epaper/display.py | 2 +- esphome/config_validation.py | 2 +- esphome/cpp_generator.py | 2 +- esphome/espidf/component.py | 2 +- pyproject.toml | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 6eb577e5ad..c892ec1112 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None: elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) else: - raise NotImplementedError() + raise NotImplementedError if config[CONF_COMPONENTS] == "all": num_components = len(list(components_dir.glob("*/__init__.py"))) diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index 5db7a1fc3d..7ecc3b4a87 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -236,7 +236,7 @@ async def to_code(config): rhs = model.new() var = cg.Pvariable(config[CONF_ID], rhs, model) else: - raise NotImplementedError() + raise NotImplementedError await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c993c1dcc5..ca1fd8f5d4 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1862,7 +1862,7 @@ def extract_keys(schema): elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str): keys.append(skey.schema) else: - raise ValueError() + raise ValueError keys.sort() return keys diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c622207dac..c5e398b2d7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -893,7 +893,7 @@ class MockObj(Expression): def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects if attr.startswith("__"): - raise AttributeError() + raise AttributeError next_op = "." if attr.startswith("P") and self.op not in ["::", ""]: attr = attr[1:] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 3534ac82f5..81f2cd9632 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -55,7 +55,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: def download(self, dir_suffix: str, force: bool = False) -> Path: - raise NotImplementedError() + raise NotImplementedError class URLSource(Source): diff --git a/pyproject.toml b/pyproject.toml index 8916abd6b9..0e7bba82e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ select = [ "PERF", # performance "PL", # pylint "Q", # flake8-quotes + "RSE", # flake8-raise "SIM", # flake8-simplify "SLOT", # flake8-slots "RET", # flake8-ret From b39b34bfe1f866c774719638552117ba5a2d3cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:26 -0500 Subject: [PATCH 138/282] [core] Enable ruff C4 (flake8-comprehensions) lint family (#16653) --- esphome/components/font/__init__.py | 4 ++-- esphome/components/libretiny/__init__.py | 14 +++++++------- esphome/components/lvgl/__init__.py | 2 +- esphome/components/lvgl/defines.py | 2 +- esphome/components/opentherm/generate.py | 10 +++------- esphome/components/time/__init__.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/pins.py | 4 +--- pyproject.toml | 1 + script/ci-custom.py | 2 +- script/extract_automations.py | 6 +++--- 11 files changed, 22 insertions(+), 27 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a10c45a9d7..cb4b1d3a60 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -563,13 +563,13 @@ async def to_code(config): point_set.update(flatten(config[CONF_GLYPHS])) # Create the codepoint to font file map base_font = FONT_CACHE[config[CONF_FILE]] - point_font_map: dict[str, Face] = {c: base_font for c in point_set} + point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font) # process extras, updating the map and extending the codepoint list for extra in config[CONF_EXTRAS]: extra_points = flatten(extra[CONF_GLYPHS]) point_set.update(extra_points) extra_font = FONT_CACHE[extra[CONF_FILE]] - point_font_map.update({c: extra_font for c in extra_points}) + point_font_map.update(dict.fromkeys(extra_points, extra_font)) codepoints = list(point_set) codepoints.sort(key=functools.cmp_to_key(glyph_comparator)) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40fb773784..d1f1042501 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,13 +513,13 @@ async def component_to_code(config): # apply LibreTiny options from framework: block # setup LT logger to work nicely with ESPHome logger - lt_options = dict( - LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL], - LT_LOGGER_CALLER=0, - LT_LOGGER_TASK=0, - LT_LOGGER_COLOR=1, - LT_USE_TIME=1, - ) + lt_options = { + "LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL], + "LT_LOGGER_CALLER": 0, + "LT_LOGGER_TASK": 0, + "LT_LOGGER_COLOR": 1, + "LT_USE_TIME": 1, + } # enable/disable per-module debugging for module in framework[CONF_DEBUG]: if module == "NONE": diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 44bcda9ba9..6e005f897e 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -174,7 +174,7 @@ def generate_lv_conf_h(): if clashes: LOGGER.warning( "Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s", - sorted(list(clashes)), + sorted(clashes), ) unused_defines = all_defines - lv_defines.keys() - defines_from_flags diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15a24f1ad2..d9be881a7f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -335,7 +335,7 @@ TYPE_NONE = "none" DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP") -LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ +LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [ "dejavu_16_persian_hebrew", "simsun_16_cjk", "unscii_8", diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py index 0b39895798..1c0de329e5 100644 --- a/esphome/components/opentherm/generate.py +++ b/esphome/components/opentherm/generate.py @@ -16,7 +16,7 @@ def define_has_component(component_type: str, keys: list[str]) -> None: cg.add_define( f"OPENTHERM_{component_type.upper()}_LIST(F, sep)", cg.RawExpression( - " sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys)) + " sep ".join(f"F({key}_{component_type.lower()})" for key in keys) ), ) for key in keys: @@ -30,12 +30,8 @@ def define_has_settings(keys: list[str], schemas: dict[str, SettingSchema]) -> N "OPENTHERM_SETTING_LIST(F, sep)", cg.RawExpression( " sep ".join( - map( - lambda key: ( - f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" - ), - keys, - ) + f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})" + for key in keys ) ), ) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 8839a988a1..e9f6cd77e5 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -204,7 +204,7 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None): raise cv.Invalid( f"{name} {v} is out of range (min={min_value} max={max_value})." ) - return list(sorted(value)) + return sorted(value) value = cv.string(value) values = set() for part in value.split(","): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index fb53066edb..f393600732 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -40,7 +40,7 @@ def _str_to_lst_of_str(a: str | list[str]) -> list[str]: """ if isinstance(a, list): return a - return list(f.strip() for f in a.split(";") if f.strip()) + return [f.strip() for f in a.split(";") if f.strip()] ESPHOME_STAMP_FILE = ".esphome.stamp.json" diff --git a/esphome/pins.py b/esphome/pins.py index bdaa0e28ab..3e7848949f 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -313,9 +313,7 @@ def gpio_base_schema( :return: A schema for the pin """ mode_default = len(modes) == 1 - mode_dict = dict( - map(lambda m: (cv.Optional(m, default=mode_default), cv.boolean), modes) - ) + mode_dict = {cv.Optional(m, default=mode_default): cv.boolean for m in modes} def _number_validator(value): if isinstance(value, str) and value.upper().startswith("GPIOX"): diff --git a/pyproject.toml b/pyproject.toml index 0e7bba82e9..2b4a7272f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/script/ci-custom.py b/script/ci-custom.py index 51fea97874..c241343a1b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -341,7 +341,7 @@ def lint_const_ordered(fname, content): matching = [ (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] - ordered = list(sorted(matching, key=lambda x: x[1].replace("_", " "))) + ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] for (mi, mline), (_, ol) in zip(matching, ordered): if mline == ol: diff --git a/script/extract_automations.py b/script/extract_automations.py index 4e650ce25f..3cdfb5d32c 100755 --- a/script/extract_automations.py +++ b/script/extract_automations.py @@ -12,9 +12,9 @@ if __name__ == "__main__": components = get_components_with_dependencies(files, True) dump = { - "actions": sorted(list(ACTION_REGISTRY.keys())), - "conditions": sorted(list(CONDITION_REGISTRY.keys())), - "pin_providers": sorted(list(PIN_SCHEMA_REGISTRY.keys())), + "actions": sorted(ACTION_REGISTRY.keys()), + "conditions": sorted(CONDITION_REGISTRY.keys()), + "pin_providers": sorted(PIN_SCHEMA_REGISTRY.keys()), } print(json.dumps(dump, indent=2)) From e492f8f8b6ba03f0cbe994d666e33215385800e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:14:36 -0500 Subject: [PATCH 139/282] [tests] Disable hypothesis deadline on flaky IP address test (#16652) --- tests/unit_tests/test_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index bb00a15bee..efc2d8e42a 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -7,7 +7,7 @@ import stat from unittest.mock import MagicMock, patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr -from hypothesis import given +from hypothesis import given, settings from hypothesis.strategies import ip_addresses import pytest @@ -151,6 +151,7 @@ def test_is_ip_address__invalid(host): assert actual is False +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): actual = helpers.is_ip_address(value) From dd0028c1b5a38b157143b267cd883fc0db277003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:36:49 -0500 Subject: [PATCH 140/282] [core] Enable ruff G (flake8-logging-format) lint family (#16650) --- esphome/components/time/__init__.py | 2 +- esphome/loader.py | 4 ++-- esphome/pins.py | 3 ++- esphome/platformio/toolchain.py | 2 +- pyproject.toml | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index e9f6cd77e5..f687df26c2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -96,7 +96,7 @@ def _extract_tz_string(tzfile: bytes) -> str: return tzfile.split(b"\n")[-2].decode() except (IndexError, UnicodeDecodeError): _LOGGER.error("Could not determine TZ string. Please report this issue.") - _LOGGER.error("tzfile contents: %s", tzfile, exc_info=True) + _LOGGER.exception("tzfile contents: %s", tzfile) raise diff --git a/esphome/loader.py b/esphome/loader.py index d50554f8c9..c57c09274e 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -239,12 +239,12 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: "Unable to import component %s: %s", domain, str(e), exc_info=False ) else: - _LOGGER.error("Unable to import component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to import component %s:", domain) return None except Exception: # pylint: disable=broad-except if exception: raise - _LOGGER.error("Unable to load component %s:", domain, exc_info=True) + _LOGGER.exception("Unable to load component %s:", domain) return None manif = ComponentManifest(module) diff --git a/esphome/pins.py b/esphome/pins.py index 3e7848949f..d6393508ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,9 +272,10 @@ 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( - f"GPIO{num} 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, ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 073e134ac4..c81420e6ca 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -96,7 +96,7 @@ def _run_idedata(config): try: return json.loads(match.group()) except ValueError: - _LOGGER.error("Could not parse idedata", exc_info=True) + _LOGGER.exception("Could not parse idedata") _LOGGER.error("Stdout: %s", stdout) raise diff --git a/pyproject.toml b/pyproject.toml index 2b4a7272f2..c8f4d88351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "FA", # flake8-future-annotations "FLY", # flynt: convert string formatting to f-strings "FURB", # refurb + "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "ISC", # flake8-implicit-str-concat From 489cf483d0f7edd2373ada70efa284dc58e182ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 20:55:35 -0500 Subject: [PATCH 141/282] [core] Enable ruff PYI (flake8-pyi) lint family (#16654) --- esphome/cpp_generator.py | 22 ++++++++++++---------- esphome/mqtt.py | 2 +- esphome/yaml_util.py | 2 +- pyproject.toml | 1 + tests/unit_tests/test_main.py | 6 +++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c5e398b2d7..151018baa4 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1077,43 +1077,45 @@ class MockObj(Expression): op = BinOpExpression(other, "|", self) return MockObj(op) - def __iadd__(self, other: SafeExpType) -> "MockObj": + # MockObj operator overloads build a new C++ expression rather than mutating self, + # so the PYI034 "augmented assignment returns self" assumption does not apply. + def __iadd__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "+=", other) return MockObj(op) - def __isub__(self, other: SafeExpType) -> "MockObj": + def __isub__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "-=", other) return MockObj(op) - def __imul__(self, other: SafeExpType) -> "MockObj": + def __imul__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "*=", other) return MockObj(op) - def __itruediv__(self, other: SafeExpType) -> "MockObj": + def __itruediv__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "/=", other) return MockObj(op) - def __imod__(self, other: SafeExpType) -> "MockObj": + def __imod__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "%=", other) return MockObj(op) - def __ilshift__(self, other: SafeExpType) -> "MockObj": + def __ilshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "<<=", other) return MockObj(op) - def __irshift__(self, other: SafeExpType) -> "MockObj": + def __irshift__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, ">>=", other) return MockObj(op) - def __iand__(self, other: SafeExpType) -> "MockObj": + def __iand__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "&=", other) return MockObj(op) - def __ixor__(self, other: SafeExpType) -> "MockObj": + def __ixor__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "^=", other) return MockObj(op) - def __ior__(self, other: SafeExpType) -> "MockObj": + def __ior__(self, other: SafeExpType) -> "MockObj": # noqa: PYI034 op = BinOpExpression(self, "|=", other) return MockObj(op) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index ccacbaea54..098292f599 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -159,7 +159,7 @@ def get_esphome_device_ip( username: str | None = None, password: str | None = None, client_id: str | None = None, - timeout: int | float = 25, + timeout: float = 25, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 9a36ad089c..28f72ab831 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -763,7 +763,7 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> def _load_yaml_internal_with_type( - loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], + loader_type: type[ESPHomeLoader | ESPHomePurePythonLoader], fname: Path, content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], diff --git a/pyproject.toml b/pyproject.toml index c8f4d88351..a094b05efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise "SIM", # flake8-simplify diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6ec0069b3a..f6b6d0b05f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,7 +11,7 @@ from pathlib import Path import re import sys import time -from typing import Any +from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -5110,11 +5110,11 @@ class MockSerial: self.timeout = 0.1 self._is_open = False - def __enter__(self) -> MockSerial: + def __enter__(self) -> Self: self._is_open = True return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *args: object) -> None: self._is_open = False @property From ae814cff5c51314f2daf43e89caeb71a832f636c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 21:28:14 -0500 Subject: [PATCH 142/282] [core] Enable ruff B (flake8-bugbear) lint family (#16655) --- esphome/__main__.py | 6 +++--- esphome/analyze_memory/cli.py | 6 +++--- esphome/analyze_memory/demangle.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/font/__init__.py | 4 +++- esphome/components/lvgl/lv_validation.py | 2 +- esphome/components/lvgl/widgets/tabview.py | 2 +- esphome/components/msa3xx/binary_sensor.py | 2 +- esphome/components/opentherm/output/__init__.py | 2 +- esphome/components/sensor/__init__.py | 9 ++++++--- esphome/core/__init__.py | 2 +- esphome/dashboard/status/mdns.py | 2 +- esphome/dashboard/status/ping.py | 6 +++--- esphome/dashboard/web_server.py | 9 ++++++--- esphome/writer.py | 2 +- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 4 ++-- script/build_language_schema.py | 2 +- script/ci-custom.py | 4 ++-- script/determine-jobs.py | 2 +- script/split_components_for_ci.py | 2 +- script/test_build_components.py | 4 ++-- tests/component_tests/display/test_display_metadata.py | 7 +++---- tests/component_tests/packages/test_packages.py | 10 ++-------- tests/integration/conftest.py | 6 ++++-- tests/unit_tests/test_config_normalization.py | 2 +- 27 files changed, 54 insertions(+), 50 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 07bbd89358..268164acf6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = getattr(module, "process_stacktrace") + process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', @@ -1101,7 +1101,7 @@ def upload_program( host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): + if module.upload_program(config, args, host): return 0, host except AttributeError: pass @@ -1353,7 +1353,7 @@ def _validate_bootloader_binary(binary: Path) -> None: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "show_logs")(config, args, devices): + if module.show_logs(config, args, devices): return 0 except AttributeError: pass diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 8f1f39e1d6..a856e2988d 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -509,7 +509,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" ) - for i, (symbol, demangled, size) in enumerate(large_core_symbols): + for i, (_symbol, demangled, size) in enumerate(large_core_symbols): # Core symbols only track (symbol, demangled, size) without section info, # so we don't show section labels here lines.append( @@ -601,7 +601,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):" ) - for i, (symbol, demangled, size, section) in enumerate(large_symbols): + for i, (_symbol, demangled, size, section) in enumerate(large_symbols): lines.append( f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}" ) @@ -640,7 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):" ) - for symbol, demangled, size, section in large_ram_syms[:10]: + for _symbol, demangled, size, section in large_ram_syms[:10]: # Format section label consistently by stripping leading dot section_label = section.lstrip(".") if section else "" display_name = _format_pstorage_name(demangled) diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py index 8999108b51..7dbd6d4f63 100644 --- a/esphome/analyze_memory/demangle.py +++ b/esphome/analyze_memory/demangle.py @@ -154,7 +154,7 @@ def batch_demangle( failed_count = 0 for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines + symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True ): # Add back any prefix that was removed demangled = _restore_symbol_prefix(prefix, stripped, demangled) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 7fba091730..327973a605 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -108,7 +108,7 @@ async def async_run_logs( platform_process_stacktrace = None try: module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = getattr(module, "process_stacktrace") + platform_process_stacktrace = module.process_stacktrace except (AttributeError, ImportError): _LOGGER.info( 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index cb4b1d3a60..4ea6267275 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -594,7 +594,9 @@ async def to_code(config): x.height, ] for (x, y) in zip( - glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) + glyph_args, + list(accumulate([len(x.bitmap_data) for x in glyph_args])), + strict=True, ) ] diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index a1b75182eb..27cbfff694 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -239,7 +239,7 @@ def color_retmapper(value): else: r, g, b, _ = from_rgbw(cval) return literal(f"lv_color_make({r}, {g}, {b})") - assert False + raise AssertionError(f"Unhandled lv_color value: {value!r}") def option_string(value): diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 5e9e0494dd..ee252ecf0b 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -97,7 +97,7 @@ class TabviewType(WidgetType): tab_bar = Widget(bar_obj, obj_spec) await set_obj_properties(tab_bar, tab_style) if tab_items_style: - for index, tab_conf in enumerate(config[CONF_TABS]): + for index, _tab_conf in enumerate(config[CONF_TABS]): await set_obj_properties( Widget(lv_obj.get_child(bar_obj, index), button_spec), tab_items_style, diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 793d5190af..732a0ed291 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ), key=CONF_NAME, ) - for event, icon in zip(EVENT_SENSORS, ICONS) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) } ) diff --git a/esphome/components/opentherm/output/__init__.py b/esphome/components/opentherm/output/__init__.py index 87307eb051..68977b9e34 100644 --- a/esphome/components/opentherm/output/__init__.py +++ b/esphome/components/opentherm/output/__init__.py @@ -21,7 +21,7 @@ async def new_openthermoutput( var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) + cg.add(var.set_id(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) input.generate_setters(var, config) return var diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6bbab76363..5a2ebf03c0 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1192,7 +1192,7 @@ def _std(x): def _correlation_coeff(x, y): m_x, m_y = _mean(x), _mean(y) - s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y)) + s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y, strict=True)) s_sq_x = sum((x_ - m_x) ** 2 for x_ in x) s_sq_y = sum((y_ - m_y) ** 2 for y_ in y) return s_xy / math.sqrt(s_sq_x * s_sq_y) @@ -1228,7 +1228,7 @@ def _mat_copy(m): def _mat_transpose(m): - return _mat_copy(zip(*m)) + return _mat_copy(zip(*m, strict=True)) def _mat_identity(n): @@ -1237,7 +1237,10 @@ def _mat_identity(n): def _mat_dot(a, b): b_t = _mat_transpose(b) - return [[sum(x * y for x, y in zip(row_a, col_b)) for col_b in b_t] for row_a in a] + return [ + [sum(x * y for x, y in zip(row_a, col_b, strict=True)) for col_b in b_t] + for row_a in a + ] def _mat_inverse(m): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 580d7f6477..182be38b18 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1081,7 +1081,7 @@ class EnumValue: @enum_value.setter def enum_value(self, value): - setattr(self, "_enum_value", value) + self._enum_value = value CORE = EsphomeCore() diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 881340ab24..9da9bb8f01 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -115,7 +115,7 @@ class MDNSStatus: results = await asyncio.gather( *(self.aiozc.async_resolve_host(name) for name in poll_names) ) - for name, address_list in zip(poll_names, results): + for name, address_list in zip(poll_names, results, strict=True): result = bool(address_list) host_mdns_state[name] = result for entry in poll_names[name]: diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py index b4f106d21a..eb69fbb9b3 100644 --- a/esphome/dashboard/status/ping.py +++ b/esphome/dashboard/status/ping.py @@ -83,7 +83,7 @@ class PingStatus: return_exceptions=True, ) - for entry, result in zip(ping_group, dns_results): + for entry, result in zip(ping_group, dns_results, strict=True): if isinstance(result, Exception): # Only update state if its unknown or from ping # so we don't mark it as offline if we have a state @@ -106,7 +106,7 @@ class PingStatus: return_exceptions=True, ) - for entry_addresses, result in zip(entry_addresses, results): + for entry_address, result in zip(entry_addresses, results, strict=True): if isinstance(result, Exception): ping_result = False elif isinstance(result, BaseException): @@ -114,7 +114,7 @@ class PingStatus: else: host: Host = result ping_result = host.is_alive - entry: DashboardEntry = entry_addresses[0] + entry: DashboardEntry = entry_address[0] # If we can reach it via ping, we always set it # online, however if we can't reach it via ping # we only set it to offline if the state is unknown diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 916e937a53..88b454e5cf 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1030,7 +1030,7 @@ class DownloadListRequestHandler(BaseHandler): try: module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = getattr(module, "get_download_types") + get_download_types = module.get_download_types except AttributeError as exc: raise ValueError(f"Unknown platform {platform}") from exc downloads = get_download_types(storage_json) @@ -1146,7 +1146,7 @@ class MainRequestHandler(BaseHandler): begin = bool(self.get_argument("begin", False)) if settings.using_password: # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement + self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 else: self.clear_cookie("_xsrf") @@ -1519,7 +1519,10 @@ def get_static_file_url(name: str) -> str: return f"{base}?hash={hash_}" -def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: +def make_app(debug: bool | None = None) -> tornado.web.Application: + if debug is None: + debug = get_bool_env(ENV_DEV) + def log_function(handler: tornado.web.RequestHandler) -> None: if handler.get_status() < 400: log_method = access_log.info diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..ab014c5daa 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -358,7 +358,7 @@ def copy_src_tree(): platform = "esphome.components." + CORE.target_platform try: module = importlib.import_module(platform) - copy_files = getattr(module, "copy_files") + copy_files = module.copy_files copy_files() except AttributeError: pass diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index 5d922ea911..a4f4f46097 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -249,7 +249,7 @@ async def async_resolve_hosts( ), return_exceptions=True, ) - for host, result in zip(pending, results): + for host, result in zip(pending, results, strict=True): if isinstance(result, BaseException): _LOGGER.debug("Failed to resolve %s: %s", host, result) diff --git a/pyproject.toml b/pyproject.toml index a094b05efe..c6d96560d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ + "B", # flake8-bugbear "C4", # flake8-comprehensions "E", # pycodestyle "EXE", # flake8-executable diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 91aec91637..1cc8e1ec98 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3551,7 +3551,7 @@ static const char *const TAG = "api.service"; if id_ is not None and not mt.options.deprecated: id_to_msg_name[id_] = mt.name - for id_, (_, _, case_label) in cases: + for id_, (_, _, _case_label) in cases: msg_name = id_to_msg_name.get(id_, "") if msg_name in message_auth_map: needs_auth = message_auth_map[msg_name] @@ -3614,7 +3614,7 @@ static const char *const TAG = "api.service"; # Dispatch switch out += " switch (msg_type) {\n" - for i, (case, ifdef, case_label) in cases: + for _i, (case, ifdef, case_label) in cases: if ifdef is not None: out += _make_ifdef_line(ifdef) + "\n" diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 921ee9d3d7..9dff70af3c 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -972,7 +972,7 @@ def convert(schema, config_var, path): } elif schema_type == "use_id": if inspect.ismodule(data): - m_attr_obj = getattr(data, "CONFIG_SCHEMA") + m_attr_obj = data.CONFIG_SCHEMA use_schema = known_schemas.get(repr(m_attr_obj)) if use_schema: [output_module, output_name] = use_schema[0][1].split(".") diff --git a/script/ci-custom.py b/script/ci-custom.py index c241343a1b..f2a9681be5 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -342,8 +342,8 @@ def lint_const_ordered(fname, content): (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) - ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered)] - for (mi, mline), (_, ol) in zip(matching, ordered): + ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered, strict=True)] + for (mi, mline), (_, ol) in zip(matching, ordered, strict=True): if mline == ol: continue target = next(i for i, line in ordered if line == mline) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ef2175eb79..01b8623813 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1047,7 +1047,7 @@ def detect_memory_impact_config( # Find common platforms supported by ALL components # This ensures we can build all components together in a merged config common_platforms = set(MEMORY_IMPACT_PLATFORM_PREFERENCE) - for component, platforms in component_platforms_map.items(): + for platforms in component_platforms_map.values(): common_platforms &= platforms # Select the most preferred platform from the common set diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 0d10246bb4..7f06f50f48 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -295,7 +295,7 @@ def main() -> int: # Sort groups by signature for readability groupable_groups = [] isolated_groups = [] - for (platform, signature), group_comps in sorted(signature_groups.items()): + for (_platform, signature), group_comps in sorted(signature_groups.items()): if signature.startswith(ISOLATED_SIGNATURE_PREFIX): isolated_groups.append((signature, group_comps)) else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 43b71004eb..51f3758291 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -890,7 +890,7 @@ def run_grouped_component_tests( print("=" * 80 + "\n") # Execute grouped tests - for (platform, signature), components in grouped_components.items(): + for (platform, _signature), components in grouped_components.items(): # Only group if we have multiple components with same signature if len(components) <= 1: continue @@ -1055,7 +1055,7 @@ def test_components( # Create empty test files for each platform (or filtered platform) reference_tests: list[Path] = [] - for platform_name, base_file in platform_bases.items(): + for platform_name in platform_bases: if platform_filter and not platform_name.startswith(platform_filter): continue # Create an empty test file named to match the platform diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index e569754494..ef3f12cb73 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from esphome.components.display import ( DisplayMetaData, add_metadata, @@ -74,8 +76,5 @@ def test_add_metadata_overwrites_existing(): def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" meta = DisplayMetaData(320, 240, True, False) - try: + with pytest.raises(AttributeError): meta.width = 640 - assert False, "Expected FrozenInstanceError" - except AttributeError: - pass diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 8c809c5e91..66f946a5bd 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -510,15 +510,9 @@ def test_package_merge_by_missing_id() -> None: ], } - error_raised = False - try: + with pytest.raises(cv.Invalid) as exc_info: packages_pass(config) - assert False, "Expected validation error for missing ID" - except cv.Invalid as err: - error_raised = True - assert err.path == [CONF_SENSOR, 2] - - assert error_raised + assert exc_info.value.path == [CONF_SENSOR, 2] def test_package_list_remove_by_id() -> None: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb025ce427..e593929583 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -407,8 +407,10 @@ async def wait_and_connect_api_client( # Wait for connection with timeout try: await asyncio.wait_for(connected_future, timeout=timeout) - except TimeoutError: - raise TimeoutError(f"Failed to connect to API after {timeout} seconds") + except TimeoutError as err: + raise TimeoutError( + f"Failed to connect to API after {timeout} seconds" + ) from err if return_disconnect_event: yield client, disconnect_event diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index d70f3c24e0..4ec17b3c7c 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -67,7 +67,7 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non configs = list(config.iter_component_configs(test_config)) assert len(configs) == 2 - for domain, component, conf in configs: + for domain, _component, conf in configs: assert domain == "switch" assert "name" in conf From ae74920b814ad555a15002d12675d057b040e0e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 00:14:42 -0500 Subject: [PATCH 143/282] [core] Enable ruff PTH (flake8-use-pathlib) lint family (#16661) --- esphome/__main__.py | 4 +- esphome/analyze_memory/cli.py | 6 +-- esphome/analyze_memory/toolchain.py | 3 +- esphome/build_gen/espidf.py | 2 +- esphome/bundle.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/bme68x_bsec2/__init__.py | 2 +- .../esp32_hosted/update/__init__.py | 4 +- esphome/components/http_request/__init__.py | 4 +- esphome/components/image/__init__.py | 2 +- .../components/micro_wake_word/__init__.py | 4 +- esphome/components/nrf52/ota.py | 2 +- esphome/components/rp2040/generate_boards.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/components/zigbee/zigbee_esp32.py | 5 +-- esphome/dashboard/dashboard.py | 3 +- esphome/dashboard/web_server.py | 6 +-- esphome/espidf/component.py | 18 ++++---- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 44 +++++++++++-------- esphome/espidf/get_idf_tool_paths.py | 3 +- esphome/espidf/runner.py | 3 +- esphome/espidf/toolchain.py | 19 ++++---- esphome/espota2.py | 2 +- esphome/helpers.py | 4 +- esphome/mqtt.py | 6 +-- esphome/web_server_ota.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 12 ++--- script/build_helpers.py | 2 +- script/bump-version.py | 5 ++- script/ci-custom.py | 2 +- script/ci_add_metadata_to_json.py | 4 +- script/ci_helpers.py | 3 +- script/ci_memory_impact_comment.py | 2 +- script/ci_memory_impact_extract.py | 4 +- script/clang-format | 3 +- script/clang-tidy | 9 ++-- script/clang_tidy_hash.py | 6 +-- script/determine-jobs.py | 2 +- script/helpers.py | 19 ++++---- script/lint-python | 6 ++- script/sync-device_class.py | 5 ++- script/test_build_components.py | 2 +- tests/dashboard/test_web_server_paths.py | 10 ++--- tests/integration/conftest.py | 2 +- tests/script/test_check_import_time.py | 7 +-- tests/script/test_determine_jobs.py | 7 +-- tests/script/test_helpers.py | 4 +- tests/script/test_test_helpers.py | 5 +-- tests/unit_tests/core/test_config.py | 20 ++++----- tests/unit_tests/test_espidf_component.py | 2 +- tests/unit_tests/test_substitutions.py | 3 +- tests/unit_tests/test_writer.py | 8 ++-- 54 files changed, 162 insertions(+), 155 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 268164acf6..5f281ce832 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -794,7 +794,7 @@ def _check_and_emit_build_info() -> None: # Read build_info from JSON try: - with open(build_info_json_path, encoding="utf-8") as f: + with build_info_json_path.open(encoding="utf-8") as f: build_info = json.load(f) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Failed to read build_info: %s", e) @@ -1056,7 +1056,7 @@ def _wait_for_serial_port( def _port_found() -> bool: if port is not None: if os.name == "posix": - return os.path.exists(port) + return Path(port).exists() return any(p.path == port for p in get_serial_ports()) ports = get_serial_ports() if known_ports is not None: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index a856e2988d..4fbceb7e5e 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Callable import heapq from operator import itemgetter +from pathlib import Path import sys from typing import TYPE_CHECKING @@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): content = "\n".join(lines) if output_file: - with open(output_file, "w", encoding="utf-8") as f: + with Path(output_file).open("w", encoding="utf-8") as f: f.write(content) else: print(content) @@ -737,7 +738,6 @@ def main(): # Load build directory import json - from pathlib import Path from esphome.platformio.toolchain import IDEData @@ -785,7 +785,7 @@ def main(): if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 3a8a5f7be4..a724d52f25 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from pathlib import Path import subprocess from typing import TYPE_CHECKING @@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None: Full path to the tool or None if not found """ # Get PlatformIO packages directory - platformio_home = Path(os.path.expanduser("~/.platformio/packages")) + platformio_home = Path("~/.platformio/packages").expanduser() if not platformio_home.exists(): return None diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 96f84ebbd1..0b50f72382 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None: return None try: - with open(project_desc, encoding="utf-8") as f: + with project_desc.open(encoding="utf-8") as f: data = json.load(f) component_info = data.get("build_component_info", {}) diff --git a/esphome/bundle.py b/esphome/bundle.py index 4537cbce9d..d38f68ebfd 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -412,7 +412,7 @@ class ConfigBundleCreator: @staticmethod def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: """Add a BundleFile to the tar archive with deterministic metadata.""" - with open(bf.source, "rb") as f: + with bf.source.open("rb") as f: _add_bytes_to_tar(tar, bf.path, f.read()) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 23c90e9b76..8dc546cec1 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: else: raise cv.Invalid("Unsupported file source") - with open(path, "rb") as f: + with path.open("rb") as f: data = f.read() try: diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5083d283ef..62cd9e2e36 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -169,7 +169,7 @@ async def to_code_base(config): path = _compute_local_file_path(_compute_url(config)) try: - with open(path, encoding="utf-8") as f: + with path.open(encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: raise core.EsphomeError( diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index b258a26b08..202df21ab5 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None: return path = CORE.relative_config_path(config[CONF_PATH]) - with open(path, "rb") as f: + with path.open("rb") as f: firmware_data = f.read() calculated = hashlib.sha256(firmware_data).hexdigest() expected = config[CONF_SHA256].lower() @@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None: if config[CONF_TYPE] == TYPE_EMBEDDED: path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: + with CORE.relative_config_path(path).open("rb") as f: firmware_data = f.read() rhs = [HexInt(x) for x in firmware_data] arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 90879c459e..2617951f0d 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,3 +1,5 @@ +from pathlib import Path + from esphome import automation import esphome.codegen as cg from esphome.components import esp32 @@ -174,7 +176,7 @@ async def to_code(config): if config.get(CONF_VERIFY_SSL): if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): - with open(ca_cert_path, encoding="utf-8") as f: + with Path(ca_cert_path).open(encoding="utf-8") as f: ca_cert_content = f.read() cg.add(var.set_ca_certificate(ca_cert_content)) else: diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 365554f7d2..2fefbdcd58 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -395,7 +395,7 @@ def download_image(value): def is_svg_file(file): if not file: return False - with open(file, "rb") as f: + with Path(file).open("rb") as f: return " tuple[dict, dict]: for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) build = data.get("build", {}) @@ -136,7 +136,7 @@ def _get_variant(json_file: Path) -> str | None: """Get variant name from a board JSON file.""" if not json_file.exists(): return None - with open(json_file, encoding="utf-8") as f: + with json_file.open(encoding="utf-8") as f: data = json.load(f) return data.get("build", {}).get("variant") diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 84910b6f90..99a9b7518c 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -326,12 +326,12 @@ async def to_code(config): if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) - with open(file=path, encoding="utf-8") as css_file: + with path.open(encoding="utf-8") as css_file: add_resource_as_progmem("CSS_INCLUDE", css_file.read()) if CONF_JS_INCLUDE in config: cg.add_define("USE_WEBSERVER_JS_INCLUDE") path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) - with open(file=path, encoding="utf-8") as js_file: + 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])) if CONF_LOCAL in config and config[CONF_LOCAL]: diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 89efd583ab..a0fadbce8b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -129,9 +129,8 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: if CONF_PARTITIONS in fv.full_config.get() and not isinstance( fv.full_config.get()[CONF_PARTITIONS], list ): - with open( - CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), - encoding="utf8", + with CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]).open( + encoding="utf8" ) as f: partitions_tab = f.read() for partition, types in [ diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 81c10763e7..7fc21f8a44 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor import contextlib import logging import os +from pathlib import Path import socket import threading from time import monotonic @@ -149,4 +150,4 @@ async def async_start(args) -> None: await dashboard.async_run() finally: if sock: - os.remove(sock) + Path(sock).unlink() diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 88b454e5cf..97d6639c1f 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1040,7 +1040,7 @@ class DownloadListRequestHandler(BaseHandler): class DownloadBinaryRequestHandler(BaseHandler): def _load_file(self, path: str, compressed: bool) -> bytes: """Load a file from disk and compress it if requested.""" - with open(path, "rb") as f: + with Path(path).open("rb") as f: data = f.read() if compressed: return gzip.compress(data, 9) @@ -1292,7 +1292,7 @@ class EditRequestHandler(BaseHandler): def _read_file(self, filename: str, configuration: str) -> bytes | None: """Read a file and return the content as bytes.""" try: - with open(file=filename, encoding="utf-8") as f: + with Path(filename).open(encoding="utf-8") as f: return f.read() except FileNotFoundError: if configuration in const.SECRETS_FILES: @@ -1493,7 +1493,7 @@ def get_base_frontend_path() -> Path: static_path += "/" # This path can be relative, so resolve against the root or else templates don't work - path = Path(os.getcwd()) / static_path / "esphome_dashboard" + path = Path.cwd() / static_path / "esphome_dashboard" return path.resolve() diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 81f2cd9632..050002d9e2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -317,24 +317,26 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s if pattern.endswith("/"): pattern = pattern.rstrip("/") + "/**" - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 matched = [] - for item in glob.glob(full_pattern, recursive=True): - if not os.path.isdir(item): + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): matched.append(item) else: # PlatformIO quirk: a directory matched with "*" should include all its # nested files and subdirectories, not just the directory itself. for root, _, files in os.walk(item): - matched.extend([os.path.join(root, f) for f in files]) + matched.extend([str(Path(root) / f) for f in files]) if sign == "+": selected.update(matched) elif sign == "-": selected.difference_update(matched) - return [r for r in selected if os.path.isfile(r)] + return [r for r in selected if Path(r).is_file()] def _convert_library_to_component(library: Library) -> IDFComponent: @@ -486,7 +488,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # Only keep sources build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] build_src_files = [ - f for f in build_src_files if os.path.splitext(f)[1] in SRC_FILE_EXTENSIONS + f for f in build_src_files if Path(f).suffix in SRC_FILE_EXTENSIONS ] # Handle build flags @@ -740,7 +742,7 @@ def _parse_library_json(library_json_path: PathType): Returns: dict: Parsed JSON content as a Python dictionary. """ - with open(library_json_path, encoding="utf8") as fp: + with Path(library_json_path).open(encoding="utf8") as fp: return json.load(fp) @@ -754,7 +756,7 @@ def _parse_library_properties(library_properties_path: PathType): Returns: dict[str, str]: Mapping of parsed property keys to values. """ - with open(library_properties_path, encoding="utf8") as fp: + with Path(library_properties_path).open(encoding="utf8") as fp: data = {} for line in fp.read().splitlines(): line = line.strip() diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 2f22f23c10..bead63ca21 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -108,7 +108,7 @@ def run_extra_script( """ env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") code = compile(script_path.read_text(), str(script_path), "exec") - old_cwd = os.getcwd() + old_cwd = Path.cwd() try: os.chdir(library_dir) exec( # noqa: S102 pylint: disable=exec-used diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f393600732..331c2f84b0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -138,7 +138,7 @@ def rmdir(directory: PathType, msg: str | None = None): Raises: RuntimeError: If directory removal fails """ - if os.path.isdir(directory): + if Path(directory).is_dir(): try: if msg: _LOGGER.debug(msg) @@ -192,7 +192,7 @@ def _check_stamp(file: PathType, data: dict[str, str]) -> bool: return False try: - with open(file, encoding="utf-8") as f: + with Path(file).open(encoding="utf-8") as f: return json.load(f) == data except (json.JSONDecodeError, OSError): return False @@ -206,7 +206,7 @@ def _write_stamp(file: PathType, data: dict[str, str]): file: Path to the stamp file to write data: Dictionary containing data to write """ - with open(file, "w", encoding="utf8") as fp: + with Path(file).open("w", encoding="utf8") as fp: json.dump(data, fp) @@ -471,8 +471,12 @@ def _tar_extract_all( import stat import tarfile + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 with tarfile.open(fileobj=data, mode="r") as tar_ref: all_members = tar_ref.getmembers() @@ -491,8 +495,8 @@ def _tar_extract_all( name = name.lstrip("/" + os.sep) # 2. Reject absolute paths (incl. Windows drive) - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -506,7 +510,7 @@ def _tar_extract_all( name = norm[len(strip_prefix) :] # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 if os.path.commonpath([abs_dest, target_path]) != abs_dest: continue @@ -515,18 +519,20 @@ def _tar_extract_all( linkname = member.linkname # Reject absolute link targets - if os.path.isabs(linkname): + if Path(linkname).is_absolute(): continue # Strip leading slashes linkname = os.path.normpath(linkname) if member.issym(): - link_target = os.path.join( - abs_dest, os.path.dirname(name), linkname + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, ) else: - link_target = os.path.join(abs_dest, linkname) + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 link_target = os.path.realpath(link_target) if os.path.commonpath([abs_dest, link_target]) != abs_dest: @@ -598,7 +604,9 @@ def _zip_extract_all( """ import zipfile - extract_dir = os.path.abspath(extract_dir) + # See note in archive_extract_all_tar: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 with zipfile.ZipFile(data, "r") as zip_ref: all_members = zip_ref.infolist() @@ -618,8 +626,8 @@ def _zip_extract_all( name = member.filename.lstrip("/\\") # 2. Reject absolute paths / Windows drives - if os.path.isabs(name) or ( - os.name == "nt" and ":" in name.split(os.sep)[0] + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue @@ -633,7 +641,7 @@ def _zip_extract_all( name = norm[len(strip_prefix) :] # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 if os.path.commonpath([extract_dir, target_path]) != extract_dir: raise ValueError(f"Unsafe path detected: {member.filename}") @@ -680,7 +688,7 @@ def archive_extract_all( with ExitStack() as stack: archive_ref: io.BufferedIOBase if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(open(archive, "rb")) + archive_ref = stack.enter_context(Path(archive).open("rb")) elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): archive_ref = archive elif isinstance(archive, io.RawIOBase): @@ -727,7 +735,7 @@ def download_from_mirrors( # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(open(target, "wb")) + f = stack.enter_context(Path(target).open("wb")) elif isinstance(target, (io.RawIOBase, io.IOBase)): f = target else: @@ -917,7 +925,7 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: return try: - with open(tools_json, encoding="utf-8") as f: + with tools_json.open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.warning( diff --git a/esphome/espidf/get_idf_tool_paths.py b/esphome/espidf/get_idf_tool_paths.py index 2e8859631d..7d99e629b1 100644 --- a/esphome/espidf/get_idf_tool_paths.py +++ b/esphome/espidf/get_idf_tool_paths.py @@ -10,6 +10,7 @@ not installed. import json import os +from pathlib import Path import sys from types import SimpleNamespace @@ -25,7 +26,7 @@ from idf_tools import ( g.idf_path = sys.argv[1] g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH") -g.tools_json = os.path.join(g.idf_path, TOOLS_FILE) +g.tools_json = str(Path(g.idf_path) / TOOLS_FILE) tools_info = filter_tools_info(IDFEnv.get_idf_env(), load_tools_info()) args = SimpleNamespace(prefer_system=False) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 857d16c674..7c568db7be 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -91,6 +91,7 @@ def main() -> int: # ---- end sys.path fix-up ----------------------------------------------- import os + from pathlib import Path import re import runpy @@ -229,7 +230,7 @@ def main() -> int: # runpy.run_path does not do this automatically, but idf.py relies # on it to import its sibling modules (python_version_checker, # idf_py_actions, ...). - script_dir = os.path.dirname(os.path.abspath(script_path)) + script_dir = str(Path(script_path).resolve().parent) if script_dir not in sys.path: sys.path.insert(0, script_dir) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index ef28575caa..752f582e74 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -241,20 +241,21 @@ def has_outdated_files(): dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") - if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + if not build_config_path.is_dir() or not any(build_config_path.iterdir()): return True - if not os.path.isfile(cmakecache_txt_path): + if not cmakecache_txt_path.is_file(): return True - if not os.path.isfile(build_ninja_path): + if not build_ninja_path.is_file(): return True - if os.path.isfile(dependency_lock_path) and os.path.getmtime( - dependency_lock_path - ) > os.path.getmtime(build_ninja_path): + if ( + dependency_lock_path.is_file() + and dependency_lock_path.stat().st_mtime > build_ninja_path.stat().st_mtime + ): return True - cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( - os.path.getmtime(f) > cmakecache_txt_mtime + f.stat().st_mtime > cmakecache_txt_mtime for f in [sdkconfig_internal_path, idf_component_yml_path] if f.exists() ) @@ -452,7 +453,7 @@ def create_factory_bin() -> bool: return False try: - with open(flasher_args_path, encoding="utf-8") as f: + with flasher_args_path.open(encoding="utf-8") as f: flash_data = json.load(f) except (json.JSONDecodeError, OSError) as e: _LOGGER.error("Failed to read flasher_args.json: %s", e) diff --git a/esphome/espota2.py b/esphome/espota2.py index 701a125bcd..266702c142 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -517,7 +517,7 @@ def run_ota_impl_( continue _LOGGER.info("Connected to %s", sa[0]) - with open(filename, "rb") as file_handle: + with Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index d7ddb5c416..733474c9c9 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -385,7 +385,7 @@ def rmtree(path: Path | str) -> None: def _onerror(func, path, exc_info): if os.access(path, os.W_OK): raise exc_info[1].with_traceback(exc_info[2]) - os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different @@ -512,7 +512,7 @@ def copy_file_if_changed(src: Path, dst: Path) -> bool: # -> delete file (it would be overwritten anyway), and try again # if that fails, use normal error handler with suppress(OSError): - os.unlink(dst) + Path(dst).unlink() shutil.copyfile(src, dst) return True diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 098292f599..d6bde0cbfd 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,7 +2,7 @@ import contextlib from datetime import datetime import json import logging -import os +from pathlib import Path import ssl import tempfile import time @@ -120,8 +120,8 @@ def prepare( key_file.close() context.load_cert_chain(cert_file.name, key_file.name) finally: - os.unlink(cert_file.name) - os.unlink(key_file.name) + Path(cert_file.name).unlink() + Path(key_file.name).unlink() client.tls_set_context(context) try: diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 7c31c1b123..8d0fdeecff 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -126,7 +126,7 @@ def _try_upload( _LOGGER.info("Connecting to %s port %s...", ip, port) try: - with open(filename, "rb") as fh: + with filename.open("rb") as fh: streamer = _MultipartStreamer(fh, file_size, filename.name) try: response = requests.post( diff --git a/pyproject.toml b/pyproject.toml index c6d96560d5..ae1bd34f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "NPY", # numpy-specific rules "PERF", # performance "PL", # pylint + "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RSE", # flake8-raise diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1cc8e1ec98..240ee7890f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3163,7 +3163,7 @@ def main() -> None: defines_content += "\n" defines_content += "\nnamespace esphome::api {} // namespace esphome::api\n" - with open(root / "api_pb2_defines.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_defines.h").open("w", encoding="utf-8") as f: f.write(defines_content) content = FILE_HEADER @@ -3448,13 +3448,13 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint #endif // HAS_PROTO_MESSAGE_DUMP """ - with open(root / "api_pb2.h", "w", encoding="utf-8") as f: + with (root / "api_pb2.h").open("w", encoding="utf-8") as f: f.write(content) - with open(root / "api_pb2.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2.cpp").open("w", encoding="utf-8") as f: f.write(cpp) - with open(root / "api_pb2_dump.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_dump.cpp").open("w", encoding="utf-8") as f: f.write(dump_cpp) hpp = FILE_HEADER @@ -3641,10 +3641,10 @@ static const char *const TAG = "api.service"; } // namespace esphome::api """ - with open(root / "api_pb2_service.h", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.h").open("w", encoding="utf-8") as f: f.write(hpp) - with open(root / "api_pb2_service.cpp", "w", encoding="utf-8") as f: + with (root / "api_pb2_service.cpp").open("w", encoding="utf-8") as f: f.write(cpp) prot_file.unlink() diff --git a/script/build_helpers.py b/script/build_helpers.py index fa722aa099..52f7ee317e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -195,7 +195,7 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME if not yaml_path.is_file(): continue - with open(yaml_path) as f: + with yaml_path.open() as f: component_config = yaml.safe_load(f) if component_config and isinstance(component_config, dict): for key, value in component_config.items(): diff --git a/script/bump-version.py b/script/bump-version.py index ed927cb991..e09fc87c60 100755 --- a/script/bump-version.py +++ b/script/bump-version.py @@ -2,6 +2,7 @@ import argparse from dataclasses import dataclass +from pathlib import Path import re import sys @@ -39,12 +40,12 @@ class Version: def sub(path, pattern, repl, expected_count=1): - with open(path, encoding="utf-8") as fh: + with Path(path).open(encoding="utf-8") as fh: content = fh.read() content, count = re.subn(pattern, repl, content, flags=re.MULTILINE) if expected_count is not None: assert count == expected_count, f"Pattern {pattern} replacement failed!" - with open(path, "w", encoding="utf-8") as fh: + with Path(path).open("w", encoding="utf-8") as fh: fh.write(content) diff --git a/script/ci-custom.py b/script/ci-custom.py index f2a9681be5..1ac13e18f7 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -14,7 +14,7 @@ import time import colorama from helpers import filter_changed, git_ls_files, print_error_for_file, styled -sys.path.append(os.path.dirname(__file__)) +sys.path.append(str(Path(__file__).parent)) def find_all(a_str, sub): diff --git a/script/ci_add_metadata_to_json.py b/script/ci_add_metadata_to_json.py index 687b5131c0..e884e9a64c 100755 --- a/script/ci_add_metadata_to_json.py +++ b/script/ci_add_metadata_to_json.py @@ -44,7 +44,7 @@ def main() -> int: return 1 try: - with open(json_path, encoding="utf-8") as f: + with Path(json_path).open(encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Error loading JSON: {e}", file=sys.stderr) @@ -74,7 +74,7 @@ def main() -> int: # Write back try: - with open(json_path, "w", encoding="utf-8") as f: + with Path(json_path).open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) print(f"Added metadata to {args.json_file}", file=sys.stderr) except OSError as e: diff --git a/script/ci_helpers.py b/script/ci_helpers.py index 48b0e4bbfe..a51a857ada 100644 --- a/script/ci_helpers.py +++ b/script/ci_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from pathlib import Path def write_github_output(outputs: dict[str, str | int]) -> None: @@ -16,7 +17,7 @@ def write_github_output(outputs: dict[str, str | int]) -> None: """ github_output = os.environ.get("GITHUB_OUTPUT") if github_output: - with open(github_output, "a", encoding="utf-8") as f: + with Path(github_output).open("a", encoding="utf-8") as f: f.writelines(f"{key}={value}\n" for key, value in outputs.items()) else: for key, value in outputs.items(): diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 01316da27f..0908b99595 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -91,7 +91,7 @@ def load_analysis_json(json_path: str) -> dict | None: return None try: - with open(json_file, encoding="utf-8") as f: + with Path(json_file).open(encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Failed to load analysis JSON: {e}", file=sys.stderr) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 2aa7394b11..feacc2b1af 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -127,7 +127,7 @@ def run_detailed_analysis(build_dir: str) -> dict | None: if not idedata_path.exists(): continue try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) @@ -264,7 +264,7 @@ def main() -> int: output_path = Path(args.output_json) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: + with output_path.open("w", encoding="utf-8") as f: json.dump(output_data, f, indent=2) print(f"Saved analysis to {args.output_json}", file=sys.stderr) diff --git a/script/clang-format b/script/clang-format index 028d752c55..df45798a30 100755 --- a/script/clang-format +++ b/script/clang-format @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import subprocess @@ -70,7 +71,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [ os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp", "*.h", "*.tcc"]) ] diff --git a/script/clang-tidy b/script/clang-tidy index 1c413ffa23..56c0a9db71 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import queue import re import shutil @@ -32,7 +33,7 @@ def clang_options(idedata): cmd = [] # extract target architecture from triplet in g++ filename - triplet = os.path.basename(idedata["cxx_path"])[:-4] + triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler cmd.append("-m32") @@ -153,8 +154,8 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") - invocation.append(f"--header-filter={os.path.abspath(basepath)}/.*") - invocation.append(os.path.abspath(path)) + invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -229,7 +230,7 @@ def main(): ) args = parser.parse_args() - cwd = os.getcwd() + cwd = Path.cwd() files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])] # Exclude benchmark files — they require google benchmark headers not # available in the ESP32 toolchain and use different naming conventions. diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index d0d8438437..f478535567 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -16,7 +16,7 @@ sys.path.insert(0, str(script_dir)) def read_file_lines(path: Path) -> list[str]: """Read lines from a file.""" - with open(path) as f: + with path.open() as f: return f.readlines() @@ -65,7 +65,7 @@ def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> s def read_file_bytes(path: Path) -> bytes: """Read bytes from a file.""" - with open(path, "rb") as f: + with path.open("rb") as f: return f.read() @@ -120,7 +120,7 @@ def read_stored_hash(repo_root: Path | None = None) -> str | None: def write_file_content(path: Path, content: str) -> None: """Write content to a file.""" - with open(path, "w") as f: + with path.open("w") as f: f.write(content) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 01b8623813..417716cd77 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -306,7 +306,7 @@ def _is_clang_tidy_full_scan() -> bool: """ try: result = subprocess.run( - [os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"], + [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], capture_output=True, check=False, ) diff --git a/script/helpers.py b/script/helpers.py index cf82a89f93..c56a434edf 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -17,10 +17,10 @@ from typing import Any import colorama -root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) -basepath = os.path.join(root_path, "esphome") -temp_folder = os.path.join(root_path, ".temp") -temp_header_file = os.path.join(temp_folder, "all-include.cpp") +root_path = str(Path(__file__).resolve().parent.parent) +basepath = str(Path(root_path) / "esphome") +temp_folder = str(Path(root_path) / ".temp") +temp_header_file = str(Path(temp_folder) / "all-include.cpp") # C++ file extensions used for clang-tidy and clang-format checks CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc") @@ -339,8 +339,8 @@ def _get_github_event_data() -> dict | None: Parsed event data dictionary, or None if not available """ github_event_path = os.environ.get("GITHUB_EVENT_PATH") - if github_event_path and os.path.exists(github_event_path): - with open(github_event_path) as f: + if github_event_path and Path(github_event_path).exists(): + with Path(github_event_path).open() as f: return json.load(f) return None @@ -464,7 +464,8 @@ def _get_changed_files_from_command(command: list[str]) -> list[str]: raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") changed_files = splitlines_no_ends(proc.stdout) - changed_files = [os.path.relpath(f, os.getcwd()) for f in changed_files if f] + cwd = Path.cwd() + changed_files = [os.path.relpath(f, cwd) for f in changed_files if f] # noqa: PTH109 changed_files.sort() return changed_files @@ -499,7 +500,7 @@ def get_changed_components() -> list[str] | None: return None # Use list-components.py to get changed components - script_path = os.path.join(root_path, "script", "list-components.py") + script_path = str(Path(root_path) / "script" / "list-components.py") cmd = [script_path, "--changed"] try: @@ -619,7 +620,7 @@ def filter_changed(files: list[str]) -> list[str]: def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: - with open(file, encoding="utf-8") as handle: + with Path(file).open(encoding="utf-8") as handle: contents = handle.read() if any(v in contents for v in value): matched.append(file) diff --git a/script/lint-python b/script/lint-python index 18281c711e..e4b3314d2a 100755 --- a/script/lint-python +++ b/script/lint-python @@ -2,6 +2,7 @@ import argparse import os +from pathlib import Path import re import sys @@ -66,11 +67,12 @@ def main(): args = parser.parse_args() files = [] + cwd = Path.cwd() for path in git_ls_files(): filetypes = (".py",) - ext = os.path.splitext(path)[1] + ext = Path(path).suffix if ext in filetypes and path.startswith("esphome"): - path = os.path.relpath(path, os.getcwd()) + path = os.path.relpath(path, cwd) files.append(path) # Match against re file_name_re = re.compile("|".join(args.files)) diff --git a/script/sync-device_class.py b/script/sync-device_class.py index 121c89b8f9..660142195a 100755 --- a/script/sync-device_class.py +++ b/script/sync-device_class.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +from pathlib import Path import re # pylint: disable=import-error @@ -34,10 +35,10 @@ DOMAINS = { def sub(path, pattern, repl): - with open(path, encoding="utf-8") as handle: + with Path(path).open(encoding="utf-8") as handle: content = handle.read() content = re.sub(pattern, repl, content, flags=re.MULTILINE) - with open(path, "w", encoding="utf-8") as handle: + with Path(path).open("w", encoding="utf-8") as handle: handle.write(content) diff --git a/script/test_build_components.py b/script/test_build_components.py index 51f3758291..767b55c94b 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -297,7 +297,7 @@ def write_github_summary( test_results: List of all test results """ summary_content = format_github_summary(test_results, toolchain) - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as f: f.write(summary_content) diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py index b596ebb581..efeafbf3b5 100644 --- a/tests/dashboard/test_web_server_paths.py +++ b/tests/dashboard/test_web_server_paths.py @@ -34,9 +34,7 @@ def test_get_base_frontend_path_dev_mode() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected @@ -62,9 +60,7 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None: # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = ( - Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" - ).resolve() + expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() assert result == expected assert result.is_absolute() @@ -157,7 +153,7 @@ def test_load_file_path(tmp_path: Path) -> None: test_file = tmp_path / "test.txt" test_file.write_bytes(b"test content") - with open(test_file, "rb") as f: + with test_file.open("rb") as f: content = f.read() assert content == b"test content" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e593929583..a9c9e0686f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -79,7 +79,7 @@ def shared_platformio_cache() -> Generator[Path]: lock_file = Path.home() / ".esphome-integration-tests-init.lock" # Always acquire the lock to ensure cache is ready before proceeding - with open(lock_file, "w") as lock_fd: + with lock_file.open("w") as lock_fd: fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) # Check if the native platform is installed (the actual indicator of a populated cache) diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py index 223c58002c..528ca0701c 100644 --- a/tests/script/test_check_import_time.py +++ b/tests/script/test_check_import_time.py @@ -4,7 +4,6 @@ from __future__ import annotations import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import patch @@ -13,12 +12,10 @@ import pytest # Load the script-under-test as `check_import_time` (it's a hyphenated path # inside `script/` that mirrors the existing `determine_jobs` pattern). -script_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "script") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) sys.path.insert(0, script_dir) spec = importlib.util.spec_from_file_location( - "check_import_time", os.path.join(script_dir, "check_import_time.py") + "check_import_time", str(Path(script_dir) / "check_import_time.py") ) check_import_time = importlib.util.module_from_spec(spec) spec.loader.exec_module(check_import_time) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7bb9fe2543..ac3c6424bf 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -3,7 +3,6 @@ from collections.abc import Generator import importlib.util import json -import os from pathlib import Path import sys from unittest.mock import Mock, call, patch @@ -11,9 +10,7 @@ from unittest.mock import Mock, call, patch import pytest # Add the script directory to Python path so we can import the module -script_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "script") -) +script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve()) sys.path.insert(0, script_dir) # Import helpers module for patching @@ -22,7 +19,7 @@ import helpers # noqa: E402 import script.helpers # noqa: E402 spec = importlib.util.spec_from_file_location( - "determine_jobs", os.path.join(script_dir, "determine-jobs.py") + "determine_jobs", str(Path(script_dir) / "determine-jobs.py") ) determine_jobs = importlib.util.module_from_spec(spec) spec.loader.exec_module(determine_jobs) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 10f258aa83..82ff5e1411 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -12,9 +12,7 @@ import pytest from pytest import MonkeyPatch # Add the script directory to Python path so we can import helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import helpers # noqa: E402 diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 3149712563..a8100252da 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -1,6 +1,5 @@ """Unit tests for script/build_helpers.py manifest override and build helpers.""" -import os from pathlib import Path import sys import textwrap @@ -9,9 +8,7 @@ from unittest.mock import MagicMock, patch import pytest # Add the script directory to Python path so we can import build_helpers -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) -) +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) import build_helpers # noqa: E402 diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4ce862315d..b5b35b5172 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -486,7 +486,7 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] # Verify default build path is "build/" build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - assert build_path.endswith(os.path.join("build", "test_device")) + assert build_path.endswith(str(Path("build") / "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -523,7 +523,7 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] # Verify it uses the env var path with device name appended build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] - expected_path = os.path.join("/env/build", "test_device") + expected_path = str(Path("/env/build") / "test_device") assert build_path == expected_path or build_path == expected_path.replace( "/", os.sep ) @@ -739,7 +739,7 @@ async def test_add_includes_with_single_file( """Test add_includes copies a single header file to build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "my_header.h" @@ -769,7 +769,7 @@ async def test_add_includes_with_directory_unix( """Test add_includes copies all files from a directory on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -814,7 +814,7 @@ async def test_add_includes_with_directory_windows( """Test add_includes copies all files from a directory on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include directory with files include_dir = tmp_path / "includes" @@ -856,7 +856,7 @@ async def test_add_includes_with_multiple_sources( """Test add_includes with multiple files and directories.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create various include sources single_file = tmp_path / "single.h" @@ -884,7 +884,7 @@ async def test_add_includes_empty_directory( """Test add_includes with an empty directory doesn't fail.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create empty directory empty_dir = tmp_path / "empty" @@ -906,7 +906,7 @@ async def test_add_includes_preserves_directory_structure_unix( """Test that add_includes preserves relative directory structure on Unix.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -940,7 +940,7 @@ async def test_add_includes_preserves_directory_structure_windows( """Test that add_includes preserves relative directory structure on Windows.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create nested directory structure lib_dir = tmp_path / "lib" @@ -973,7 +973,7 @@ async def test_add_includes_overwrites_existing_files( """Test that add_includes overwrites existing files in build directory.""" CORE.config_path = tmp_path / "config.yaml" CORE.build_path = tmp_path / "build" - os.makedirs(CORE.build_path, exist_ok=True) + CORE.build_path.mkdir(parents=True, exist_ok=True) # Create include file include_file = tmp_path / "header.h" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f50f5317de..4f0a71053d 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -293,7 +293,7 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path): result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32") - assert result.libpath == [os.path.join("src", "esp32")] + assert result.libpath == [str(Path("src") / "esp32")] assert result.libs == ["algobsec"] assert ("BAR", "1") in result.cppdefines assert "FOO" in result.cppdefines diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 4783112578..c71be2fbab 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,4 +1,3 @@ -import glob import logging from pathlib import Path from typing import Any @@ -106,7 +105,7 @@ REMOTES = { # Collect all input YAML files for test_substitutions_fixtures parametrized tests: HERE = Path(__file__).parent BASE_DIR = HERE / "fixtures" / "substitutions" -SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml"))) +SOURCES = sorted(str(p) for p in BASE_DIR.glob("*.input.yaml")) assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..fc49f03067 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1358,7 +1358,7 @@ def test_clean_build_handles_readonly_files( # Create a read-only file (simulating git pack files on Windows) readonly_file = git_dir / "pack-abc123.pack" readonly_file.write_text("pack data") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1393,7 +1393,7 @@ def test_clean_all_handles_readonly_files( subdir.mkdir() readonly_file = subdir / "readonly.txt" readonly_file.write_text("content") - os.chmod(readonly_file, stat.S_IRUSR) # Read-only + readonly_file.chmod(stat.S_IRUSR) # Read-only # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1422,7 +1422,7 @@ def test_clean_build_reraises_for_other_errors( test_file.write_text("content") # Make subdir read-only so files inside can't be deleted - os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR) + subdir.chmod(stat.S_IRUSR | stat.S_IXUSR) # Setup mocks mock_core.relative_pioenvs_path.return_value = pioenvs_dir @@ -1440,7 +1440,7 @@ def test_clean_build_reraises_for_other_errors( clean_build() finally: # Cleanup - restore write permission so tmp_path cleanup works - os.chmod(subdir, stat.S_IRWXU) + subdir.chmod(stat.S_IRWXU) # Tests for get_build_info() From 423b60c90ce030f02f0bfa560464d4016301da59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:44 +1200 Subject: [PATCH 144/282] [packages] Resolve git symlinks on Windows when materialized as text (#16657) --- esphome/components/packages/__init__.py | 42 +++- esphome/git.py | 87 +++++++ tests/unit_tests/test_git.py | 303 +++++++++++++++++++++++- tests/unit_tests/test_substitutions.py | 83 +++++++ 4 files changed, 507 insertions(+), 8 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 06a64208b6..f3e0e0db8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - repo_dir, revert = git.clone_or_update( + repo_root, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: ) files: list[dict[str, Any]] = [] + # ``repo_root`` is the directory containing ``.git`` and must be passed + # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a + # subdirectory via the user's CONF_PATH and is used for file lookups. + repo_dir = repo_root if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path @@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: def _load_package_yaml(yaml_file: Path, filename: str) -> dict: """Load a YAML file from a remote package, validating min_version.""" - try: - new_yaml = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: + + def _load(path: Path) -> dict | str | None: + try: + return yaml_util.load_yaml(path) + except EsphomeError as e: + raise cv.Invalid( + f"{filename} is not a valid YAML file." + f" Please check the file contents.\n{e}" + ) from e + + new_yaml = _load(yaml_file) + if not isinstance(new_yaml, dict): + # On Windows, git defaults to core.symlinks=false unless the user + # has Developer Mode enabled or is running elevated. Files stored + # in the repo as symlinks (tree mode 120000) are then checked out + # as plain text files containing the symlink target path, so + # parsing them as YAML yields a bare scalar instead of a mapping. + # Best-effort: follow the symlink target ourselves and re-load. + target = git.resolve_symlink_stub(repo_root, yaml_file) + if target is not None: + new_yaml = _load(target) + if not isinstance(new_yaml, dict): raise cv.Invalid( - f"{filename} is not a valid YAML file." - f" Please check the file contents.\n{e}" - ) from e + f"{filename} does not contain a YAML mapping at the top level " + f"(got {type(new_yaml).__name__}). " + f"If this file is a git symlink in the source repository, it " + f"may not have been materialized correctly on your platform " + f"(this is a known issue with git on Windows without Developer " + f"Mode enabled). Try pointing your package at the real file " + f"path instead." + ) esphome_config = new_yaml.get(CONF_ESPHOME) or {} min_version = esphome_config.get(CONF_MIN_VERSION) if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse( diff --git a/esphome/git.py b/esphome/git.py index f36bd559ef..094a6dae19 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -6,6 +6,7 @@ import logging from pathlib import Path import re import subprocess +import sys import urllib.parse import esphome.config_validation as cv @@ -94,6 +95,92 @@ def _compute_destination_path(key: str, domain: str) -> Path: return base_dir / h.hexdigest()[:8] +def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None: + """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub. + + On Windows, when ``core.symlinks=false`` (the default unless the user has + SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated), + git materializes files with tree mode ``120000`` as plain text files + whose content is the literal symlink target path. Opening such a file + yields the target path string instead of the target's content. + + If ``file_path`` is one of those stubs, return the resolved target Path + inside ``repo_dir``. Otherwise return ``None`` and the caller should use + ``file_path`` as-is. + + Designed to be called *only* when normal access has already produced an + unexpected result (e.g. YAML parsed as a top-level scalar), so the + per-file ``git ls-files`` subprocess cost is paid only on the failure + path. Returns ``None`` on any error or check failure — it's purely a + best-effort recovery, never raises. + """ + # On non-Windows, git creates real symlinks; ordinary file access already + # transparently follows them. + if sys.platform != "win32": + return None + if file_path.is_symlink(): + return None + if not file_path.is_file(): + return None + + try: + rel = file_path.relative_to(repo_dir) + except ValueError: + return None + + try: + # ``git ls-files -s `` prints " \t" + # for that single entry, or empty if untracked. + out = run_git_command( + ["git", "ls-files", "-s", "--", rel.as_posix()], + git_dir=repo_dir, + ) + except GitException: + return None + + parts = out.split() + if not parts or parts[0] != "120000": + return None + + # Stubs are short ASCII relative paths. Decode defensively, and only + # strip the trailing newline git's checkout may append — preserving any + # whitespace that could be part of a valid target name. + try: + raw = file_path.read_bytes() + except OSError: + return None + try: + target_str = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + return None + + # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g. + # embedded NUL bytes from a hostile symlink blob, paths too long for the + # OS, or temporary I/O errors). Catch broadly — this helper is purely a + # best-effort recovery and must never raise. + try: + target_path = (file_path.parent / target_str).resolve() + repo_root_resolved = repo_dir.resolve() + except (OSError, ValueError, RuntimeError): + return None + + # ``Path.resolve()`` follows ``..``; re-verify containment afterwards. + try: + target_path.relative_to(repo_root_resolved) + except ValueError: + _LOGGER.warning( + "Refusing to follow symlink %s -> %s (escapes repository)", + file_path, + target_str, + ) + return None + + if not target_path.is_file(): + return None + + return target_path + + def clone_or_update( *, url: str, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index eab6bfc2cb..690c47c183 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import os from pathlib import Path from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits( "--hard", "old_sha", ] + + +def test_resolve_symlink_stub_returns_none_on_non_windows( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """On non-Windows, resolve_symlink_stub returns None without calling git.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + stub = repo_dir / "file.yaml" + stub.write_text("static/file.yaml") + + with patch("esphome.git.sys.platform", "linux"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_target_for_mode_120000( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A mode-120000 file is recognised as a stub; its target Path is returned.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "real.yaml" + target.write_text("esphome:\n name: real\n") + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\treal.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + # Stub file itself was not modified — only inspected. + assert stub.read_text() == "static/real.yaml" + + +def test_resolve_symlink_stub_resolves_relative_parent_paths( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Symlink targets with ``..`` segments resolve correctly within the repo.""" + repo_dir = tmp_path / "repo" + (repo_dir / "subdir").mkdir(parents=True) + (repo_dir / "static").mkdir() + + target = repo_dir / "static" / "shared.yaml" + target.write_text("shared content") + + stub = repo_dir / "subdir" / "shared.yaml" + stub.write_text("../static/shared.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_refuses_escape_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing outside the repository is not followed.""" + outside = tmp_path / "outside.yaml" + outside.write_text("sensitive") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "escape.yaml" + stub.write_text("../outside.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_real_symlink( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A real symlink already opens transparently, so the helper short-circuits. + + Skipped on Windows where symlink creation requires + SeCreateSymbolicLinkPrivilege. + """ + if os.name == "nt": + pytest.skip("Requires symlink-creation privilege on Windows") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target = repo_dir / "real.yaml" + target.write_text("real content") + + real_link = repo_dir / "link.yaml" + real_link.symlink_to("real.yaml") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, real_link) + + assert result is None + # No git call needed for real symlinks. + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_for_regular_file( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A regular file (mode 100644) whose content looks path-shaped is not + followed.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + regular = repo_dir / "looks_like_path.txt" + regular.write_text("static/something.yaml") + + mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, regular) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_git_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """If ``git ls-files`` fails (e.g. not a repo), the helper returns None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "real.yaml" + stub.write_text("static/real.yaml") + + mock_run_git_command.side_effect = GitCommandError("ls-files exploded") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_for_non_utf8_content( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file whose bytes are not valid UTF-8 must not raise — return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "binary.bin" + stub.write_bytes(b"\xff\xfe\x00\xff") + + mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_preserves_whitespace_in_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Only trailing CR/LF is stripped — internal whitespace is preserved.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + target_dir = repo_dir / "dir with spaces" + target_dir.mkdir() + target = target_dir / "real.yaml" + target.write_text("hello") + + stub = repo_dir / "link.yaml" + # Trailing newline (as git's checkout may append) is stripped, but + # whitespace inside the target path itself must survive. + stub.write_bytes(b"dir with spaces/real.yaml\n") + + mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result == target.resolve() + + +def test_resolve_symlink_stub_returns_none_for_directory_target( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A symlink pointing at a directory has no file content to load.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "dir_target").mkdir() + + stub = repo_dir / "link_to_dir" + stub.write_text("dir_target") + + mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_resolve_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Path.resolve() raising (e.g. on a malformed target) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "broken.yaml" + stub.write_text("ignored") + + mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "resolve", side_effect=OSError("bad path")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_file_missing( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that doesn't exist is rejected before git is consulted.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + missing = repo_dir / "ghost.yaml" # not created + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, missing) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_path_outside_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A file path that isn't under repo_dir is rejected (ValueError from relative_to).""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + outside = tmp_path / "stray.yaml" + outside.write_text("something") + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, outside) + + assert result is None + mock_run_git_command.assert_not_called() + + +def test_resolve_symlink_stub_returns_none_when_untracked( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Empty `git ls-files` output (untracked file) makes the helper return None.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "untracked.yaml" + stub.write_text("static/foo.yaml") + + mock_run_git_command.return_value = "" + + with patch("esphome.git.sys.platform", "win32"): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None + + +def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + stub = repo_dir / "racy.yaml" + stub.write_text("static/racy.yaml") + + mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml" + + with ( + patch("esphome.git.sys.platform", "win32"), + patch.object(Path, "read_bytes", side_effect=OSError("vanished")), + ): + result = git.resolve_symlink_stub(repo_dir, stub) + + assert result is None diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c71be2fbab..b5816f742e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -837,3 +837,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None: assert isinstance(result["value"], Lambda) assert result["value"].value == 'return "bar";' + + +@patch("esphome.git.resolve_symlink_stub") +@patch("esphome.git.clone_or_update") +def test_remote_package_symlink_stub_is_followed( + mock_clone_or_update: MagicMock, + mock_resolve_symlink_stub: MagicMock, + tmp_path: Path, +) -> None: + """When a package YAML is a scalar (symlink stub) and resolve_symlink_stub + returns a target, the loader follows the target and uses its content.""" + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "static").mkdir() + + # Stub file: content is the target path string (simulating Windows behavior). + stub = repo_dir / "file1.yaml" + stub.write_text("static/file1.yaml") + + # Real target with valid YAML mapping. + target = repo_dir / "static" / "file1.yaml" + target.write_text("substitutions:\n hello: world\n") + + mock_clone_or_update.return_value = (repo_dir, None) + mock_resolve_symlink_stub.return_value = target + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + # Must succeed (does not raise the helpful cv.Invalid) because the stub + # was followed and a valid mapping was loaded from the target. + do_packages_pass(config) + assert mock_resolve_symlink_stub.called + + +@patch("esphome.git.clone_or_update") +def test_remote_package_scalar_yaml_raises_helpful_error( + mock_clone_or_update: MagicMock, tmp_path: Path +) -> None: + """A remote package YAML that is a top-level scalar (e.g. an unmaterialized + git symlink on Windows) raises a clear cv.Invalid, not AttributeError. + + Regression test for the case where a repo containing a YAML symlink, + checked out on Windows without symlink privilege, lands as a short text + file containing the symlink target path. PyYAML parses that as a bare + string scalar; the package loader must reject it with a human-readable + error instead of dying inside ``.get()``. + """ + CORE.config_path = tmp_path / "test.yaml" + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + # Simulate the broken-symlink state: a YAML file whose entire content is + # the symlink target string. PyYAML parses this as a top-level scalar. + (repo_dir / "file1.yaml").write_text("static/file1.yaml") + + mock_clone_or_update.return_value = (repo_dir, None) + + config: dict[str, Any] = { + "packages": { + "test_package": { + "url": "https://github.com/esphome/repo1", + "ref": "main", + "files": ["file1.yaml"], + } + } + } + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "mapping at the top level" in msg + assert "file1.yaml" in msg From 8b62cfded7aec52d5d74b7f12751502b9ad2a059 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 145/282] [libretiny] Fix RTL8710B IRAM_ATTR section being dropped from flashed image (#16616) --- esphome/components/libretiny/hal.h | 24 +++++---- .../libretiny/patch_linker.py.script | 54 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..01a7b5450b 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 3a8a4787ed..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,12 +6,18 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject # KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) # immediately after KEEP(*(.vectors)), so the vector table stays at @@ -34,6 +40,20 @@ _KEEP_LINE = ( # aligned address; injecting before the vectors would push them to an # unaligned offset and mis-route every IRQ handler. _LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -71,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -87,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -102,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -110,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, From ceb9d406e172919020c174e7931d69916a0ceeea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 06:46:44 -0500 Subject: [PATCH 146/282] [core] Enable ruff PIE (flake8-pie) lint family (#16658) --- esphome/components/as5600/__init__.py | 2 +- esphome/components/audio_file/__init__.py | 2 +- esphome/components/font/__init__.py | 2 +- esphome/components/http_request/__init__.py | 2 +- esphome/components/image/__init__.py | 2 +- esphome/components/packages/__init__.py | 2 +- esphome/components/time/__init__.py | 6 +++--- esphome/log.py | 6 ++++-- esphome/upload_targets.py | 2 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 7 +------ script/determine-jobs.py | 10 +++------- script/helpers.py | 6 ++---- tests/integration/test_gpio_expander_cache.py | 4 ++-- tests/unit_tests/components/test_time.py | 4 ++-- 15 files changed, 25 insertions(+), 33 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 444306cec3..c05e556376 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) - if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")): + if isinstance(value, str) and value.endswith(("°", "deg")): return angle_to_position( value, min=round(min * POSITION_TO_ANGLE), diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 8dc546cec1..53193c8008 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType: def _validate_file_shorthand(value: str) -> ConfigType: value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return _file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4ea6267275..7510f2f8b6 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -401,7 +401,7 @@ def validate_file_shorthand(value): data[CONF_WEIGHT] = weight[1:] return font_file_schema(data) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return font_file_schema( { CONF_TYPE: TYPE_WEB, diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 2617951f0d..fd033dac7f 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -65,7 +65,7 @@ CONF_JSON = "json" def validate_url(value): value = cv.url(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 2fefbdcd58..5f8e5ca132 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -408,7 +408,7 @@ def validate_file_shorthand(value): raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") return download_gh_svg(parts[1], parts[0]) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return download_image(value) value = cv.file_(value) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index f3e0e0db8f..c1c5bd2ae3 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -112,7 +112,7 @@ def expand_file_to_files(config: dict): def validate_yaml_filename(value): value = cv.string(value) - if not (value.endswith(".yaml") or value.endswith(".yml")): + if not value.endswith((".yaml", ".yml")): raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.") return value diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index f687df26c2..b3bf2d44d7 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -418,11 +418,11 @@ async def setup_time_core_(time_var, config): for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - seconds = conf.get(CONF_SECONDS, list(range(0, 61))) + seconds = conf.get(CONF_SECONDS, list(range(61))) cg.add(trigger.add_seconds(seconds)) - minutes = conf.get(CONF_MINUTES, list(range(0, 60))) + minutes = conf.get(CONF_MINUTES, list(range(60))) cg.add(trigger.add_minutes(minutes)) - hours = conf.get(CONF_HOURS, list(range(0, 24))) + hours = conf.get(CONF_HOURS, list(range(24))) cg.add(trigger.add_hours(hours)) days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32))) cg.add(trigger.add_days_of_month(days_of_month)) diff --git a/esphome/log.py b/esphome/log.py index bfd1875b55..b120c930d0 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -28,10 +28,12 @@ class AnsiFore(Enum): class AnsiStyle(Enum): + # BOLD/BRIGHT and THIN/DIM are intentional ANSI synonyms; Enum treats the + # second name in each pair as an alias of the first. BRIGHT = "\033[1m" - BOLD = "\033[1m" + BOLD = "\033[1m" # noqa: PIE796 DIM = "\033[2m" - THIN = "\033[2m" + THIN = "\033[2m" # noqa: PIE796 NORMAL = "\033[22m" RESET_ALL = "\033[0m" diff --git a/esphome/upload_targets.py b/esphome/upload_targets.py index 302ecf7301..d9d9713fc1 100644 --- a/esphome/upload_targets.py +++ b/esphome/upload_targets.py @@ -57,7 +57,7 @@ def get_port_type(port: str) -> PortType: """ if port == "BOOTSEL": return PortType.BOOTSEL - if port.startswith("/") or port.startswith("COM"): + if port.startswith(("/", "COM")): return PortType.SERIAL if port == "MQTT": return PortType.MQTT diff --git a/pyproject.toml b/pyproject.toml index ae1bd34f60..6572078746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib "PYI", # flake8-pyi diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 240ee7890f..451cd9ac1f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -84,12 +84,7 @@ def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if ( - line == "" - or line.startswith("#ifdef") - or line.startswith("#if ") - or line.startswith("#endif") - ): + if line == "" or line.startswith(("#ifdef", "#if ", "#endif")): p = "" else: p = padding diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 417716cd77..d91936952e 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -483,9 +483,7 @@ def should_run_device_builder(branch: str | None = None) -> bool: True if the device-builder downstream tests should run, False otherwise. """ target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): return False for file in changed_files(branch): @@ -955,9 +953,7 @@ def detect_memory_impact_config( # all components at once would produce nonsensical memory impact results. # Memory impact analysis is most useful for focused PRs targeting dev. target_branch = get_target_branch() - if target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") - ): + if target_branch and (target_branch.startswith(("release", "beta"))): print( f"Memory impact: Skipping analysis for target branch {target_branch} " f"(would try to build all components at once, giving nonsensical results)", @@ -1311,7 +1307,7 @@ def main() -> None: # (no isolation, all components are groupable) target_branch = get_target_branch() is_release_branch = target_branch and ( - target_branch.startswith("release") or target_branch.startswith("beta") + target_branch.startswith(("release", "beta")) ) if is_release_branch: diff --git a/script/helpers.py b/script/helpers.py index c56a434edf..9839e766e2 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -103,9 +103,7 @@ def get_component_from_path(file_path: str) -> str | None: Returns: Component name if path is in components or tests directory, None otherwise """ - if file_path.startswith(ESPHOME_COMPONENTS_PATH) or file_path.startswith( - ESPHOME_TESTS_COMPONENTS_PATH - ): + if file_path.startswith((ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)): parts = file_path.split("/") if len(parts) >= 3 and parts[2]: # Verify that parts[2] is actually a component directory, not a file @@ -160,7 +158,7 @@ def is_validate_only_file(test_file: Path) -> bool: ``esphome config`` only and skipped during compile. """ name = test_file.name - return name.startswith("validate.") or name.startswith("validate-") + return name.startswith(("validate.", "validate-")) @dataclass(frozen=True) diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index e5f0f2818f..1d36ca3446 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -43,7 +43,7 @@ async def test_gpio_expander_cache( # ensure logs are in the expected order log_order = [ (digital_read_hw_pattern, 0), - [(digital_read_cache_pattern, i) for i in range(0, 8)], + [(digital_read_cache_pattern, i) for i in range(8)], (digital_read_hw_pattern, 8), [(digital_read_cache_pattern, i) for i in range(8, 16)], (digital_read_hw_pattern, 16), @@ -68,7 +68,7 @@ async def test_gpio_expander_cache( # uint16_t component tests (single bank of 16 pins) (uint16_read_hw_pattern, 0), # First pin triggers hw read [ - (uint16_read_cache_pattern, i) for i in range(0, 16) + (uint16_read_cache_pattern, i) for i in range(16) ], # All 16 pins return via cache # After cache reset (uint16_read_hw_pattern, 5), # First read after reset triggers hw diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 6325bfbe75..5ae9d787d6 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -70,11 +70,11 @@ def test_numeric_offset_slash() -> None: def test_star() -> None: - assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("*", 0, 59, {}) == set(range(60)) def test_question() -> None: - assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60)) + assert _parse_cron_part("?", 0, 59, {}) == set(range(60)) def test_range() -> None: From 88b12a1c457f171ef877b033dcf8f4231e74bf13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 08:41:54 -0500 Subject: [PATCH 147/282] [lvgl] Build automation_schema event validators lazily (#16633) --- esphome/components/lvgl/schemas.py | 34 ++++++++- .../lvgl/test_automation_schema_lazy.py | 71 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_automation_schema_lazy.py diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index b901eb4b53..bdaa91f15c 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,6 +22,7 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger +from esphome.schema_extractors import EnableSchemaExtraction from . import defines as df, lv_validation as lvalid from .defines import ( @@ -407,7 +408,34 @@ def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: return cv.Schema(part_dict(parts)) -def automation_schema(typ: LvType): +def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]: + """Return a validator that defers building the validate_automation schema. + + validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which + voluptuous compiles eagerly. automation_schema() builds ~60 of these per + widget type, and the vast majority of slots are never invoked by a given + user config. Deferring the build to first use removes that work from + schema-construction time. + + When EnableSchemaExtraction is set (build_language_schema.py), fall back + to eager construction so the @schema_extractor("automation") decoration + inside validate_automation is registered. + """ + if EnableSchemaExtraction: + return validate_automation(extra_schema) + + cached: Callable[[Any], Any] | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = validate_automation(extra_schema) + return cached(value) + + return validator + + +def automation_schema(typ: LvType) -> dict[Any, Any]: events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: events = events + (CONF_ON_VALUE, CONF_ON_UPDATE) @@ -422,7 +450,7 @@ def automation_schema(typ: LvType): return { **{ - cv.Optional(event): validate_automation( + cv.Optional(event): _lazy_validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( Trigger.template(*get_trigger_args(event)) @@ -431,7 +459,7 @@ def automation_schema(typ: LvType): ) for event in events }, - cv.Optional(CONF_ON_BOOT): validate_automation( + cv.Optional(CONF_ON_BOOT): _lazy_validate_automation( {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} ), } diff --git a/tests/component_tests/lvgl/test_automation_schema_lazy.py b/tests/component_tests/lvgl/test_automation_schema_lazy.py new file mode 100644 index 0000000000..46430824f6 --- /dev/null +++ b/tests/component_tests/lvgl/test_automation_schema_lazy.py @@ -0,0 +1,71 @@ +"""Tests for lvgl automation_schema lazy validate_automation build.""" + +from __future__ import annotations + +from unittest.mock import patch + +import esphome.components.lvgl # noqa: F401 +from esphome.components.lvgl import schemas as lvgl_schemas +from esphome.components.lvgl.schemas import ( + WIDGET_TYPES, + _lazy_validate_automation, + automation_schema, +) +from esphome.components.lvgl.widgets import WidgetType +from esphome.config_validation import GenerateID, declare_id +from esphome.const import CONF_TRIGGER_ID +from esphome.core.config import StartupTrigger + + +def _widget_type(name: str = "obj") -> WidgetType: + wt = WIDGET_TYPES.get(name) + assert wt is not None, f"widget type {name!r} not registered" + return wt + + +def _trigger_extra_schema() -> dict: + return {GenerateID(CONF_TRIGGER_ID): declare_id(StartupTrigger)} + + +def test_lazy_validator_defers_build_until_first_call() -> None: + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + validator = _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 0 + validator({"then": []}) + assert va_mock.call_count == 1 + validator({"then": []}) + assert va_mock.call_count == 1 + + +def test_eager_build_when_schema_extraction_enabled() -> None: + with ( + patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True), + patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock, + ): + _lazy_validate_automation(_trigger_extra_schema()) + assert va_mock.call_count == 1 + + +def test_lazy_and_eager_produce_equivalent_validation() -> None: + extra = _trigger_extra_schema() + with patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True): + eager = _lazy_validate_automation(extra) + lazy = _lazy_validate_automation(_trigger_extra_schema()) + sample = {"then": []} + assert lazy(sample) == eager(sample) + + +def test_automation_schema_uses_lazy_validators() -> None: + wt = _widget_type("obj") + with patch( + "esphome.components.lvgl.schemas.validate_automation", + wraps=lvgl_schemas.validate_automation, + ) as va_mock: + automation_schema(wt.w_type) + assert va_mock.call_count == 0 From 722cbfe843cad4aa729ef848016261fdb4bb884d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 26 May 2026 14:05:57 -0400 Subject: [PATCH 148/282] [voice_assistant] Never send zero-length audio to Home Assistant (#16634) --- .../voice_assistant/voice_assistant.cpp | 136 ++++++++++++------ .../voice_assistant/voice_assistant.h | 13 ++ 2 files changed, 107 insertions(+), 42 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index af1b98da02..f13ea39fa2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -4,6 +4,7 @@ #ifdef USE_VOICE_ASSISTANT #include "esphome/components/socket/socket.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include @@ -26,6 +27,11 @@ static const size_t SEND_BUFFER_SIZE = SEND_BUFFER_SAMPLES * sizeof(int16_t); static const size_t RECEIVE_SIZE = 1024; static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; +// If one microphone channel keeps producing audio while another configured channel produces none for this +// long, treat the silent channel as failed and stop the stream. A working microphone exposes a chunk every +// SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks. +static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000; + VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { @@ -168,6 +174,9 @@ void VoiceAssistant::clear_buffers_() { this->audio_source2_->clear_buffered_data(); } + // Reset the multi-channel stall watchdog (see audio_channel_stall_start_). + this->audio_channel_stall_start_ = 0; + #ifdef USE_SPEAKER if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { memset(this->speaker_buffer_, 0, SPEAKER_BUFFER_SIZE); @@ -200,6 +209,79 @@ void VoiceAssistant::reset_conversation_id() { ESP_LOGD(TAG, "reset conversation ID"); } +void VoiceAssistant::stream_api_audio_() { + // Both microphone channels are sent together, if configured. Home Assistant feeds one of the + // channels to its speech-to-text stream and treats an empty payload on that channel as + // end-of-stream, and the device cannot know which channel it picked, so only send once every + // configured channel has audio exposed, and always send them together. We don't target any + // particular message size: Home Assistant re-chunks the audio, and each fill() exposes at most + // SEND_BUFFER_SIZE bytes. + while (true) { + // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() + // reports the currently exposed bytes either way. + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + size_t available2 = 0; + if (this->audio_source2_ != nullptr) { + this->audio_source2_->fill(0, false); + available2 = this->audio_source2_->available(); + } + + const bool channel_empty = (available == 0); + const bool channel2_empty = (this->audio_source2_ != nullptr) && (available2 == 0); + if (channel_empty || channel2_empty) { + // A configured channel has no audio yet, so keep any chunk exposed on the other channel for the + // next pass rather than sending an empty payload. + this->handle_channel_stall_(available, available2); + break; + } + + // Both channels have audio exposed; clear any in-progress stall timer. + this->audio_channel_stall_start_ = 0; + + api::VoiceAssistantAudio msg; + // Zero-copy: send_message() copies the data out before we consume it. + msg.data = this->audio_source_->data(); + msg.data_len = available; + if (this->audio_source2_ != nullptr) { + msg.data2 = this->audio_source2_->data(); + msg.data2_len = available2; + } + + this->api_client_->send_message(msg); + + this->audio_source_->consume(available); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->consume(available2); + } + } +} + +void VoiceAssistant::handle_channel_stall_(size_t available, size_t available2) { + // Called when at least one configured channel has no audio exposed. When one channel has data and the + // other does not, watch how long the empty channel stays starved: Home Assistant has no stream timeout + // and would never tell us to stop, so a channel that fails outright would otherwise hang streaming + // forever with the live channel's chunk held. Stop the stream with an error after a prolonged imbalance. + if ((available == 0) && (available2 == 0)) { + // Both channels are idle (no audio buffered yet); normal, not a stalled channel. + this->audio_channel_stall_start_ = 0; + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (this->audio_channel_stall_start_ == 0) { + this->audio_channel_stall_start_ = now; + } else if ((now - this->audio_channel_stall_start_) >= AUDIO_CHANNEL_STALL_TIMEOUT_MS) { + ESP_LOGW(TAG, "Mic channel %d stalled, stopping stream", (available == 0) ? 0 : 1); + this->audio_channel_stall_start_ = 0; + this->signal_stop_(); + this->set_state_(State::STOP_MICROPHONE, State::IDLE); + this->defer([this]() { + this->error_trigger_.trigger("mic-channel-stalled", "A microphone channel stopped producing audio"); + }); + } +} + void VoiceAssistant::loop() { if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE && this->state_ != State::STOPPING_MICROPHONE) { @@ -292,55 +374,25 @@ void VoiceAssistant::loop() { case State::STREAMING_MICROPHONE: { // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { - // API audio - // Both microphone channels are sent, if configured - size_t available = this->audio_source_->fill(0, false); - size_t available2 = 0; - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - - while (available > 0 || available2 > 0) { - api::VoiceAssistantAudio msg; - - if (available > 0) { - // Zero-copy: send_message() copies the data out before we consume it - msg.data = this->audio_source_->data(); - msg.data_len = available; - } - - // Second microphone channel - if (available2 > 0) { - msg.data2 = this->audio_source2_->data(); - msg.data2_len = available2; - } - - this->api_client_->send_message(msg); - - if (available > 0) { - this->audio_source_->consume(available); - } - available = this->audio_source_->fill(0, false); - if (available2 > 0) { - this->audio_source2_->consume(available2); - } - if (this->audio_source2_ != nullptr) { - available2 = this->audio_source2_->fill(0, false); - } - } + this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used - while (this->audio_source_->fill(0, false) > 0) { + while (true) { + this->audio_source_->fill(0, false); + size_t available = this->audio_source_->available(); + if (available == 0) { + break; + } if (!this->udp_socket_running_) { if (!this->start_udp_socket_()) { this->set_state_(State::STOP_MICROPHONE, State::IDLE); break; } } - this->socket_->sendto(this->audio_source_->data(), this->audio_source_->available(), 0, - (struct sockaddr *) &this->dest_addr_, sizeof(this->dest_addr_)); - this->audio_source_->consume(this->audio_source_->available()); + this->socket_->sendto(this->audio_source_->data(), available, 0, (struct sockaddr *) &this->dest_addr_, + sizeof(this->dest_addr_)); + this->audio_source_->consume(available); } } // audio mode break; @@ -841,8 +893,8 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { }); State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE; if (new_state != this->state_) { - // Don't needlessly change the state. The intent progress stage may have already changed the state to streaming - // response. + // Don't needlessly change the state. The intent progress stage may have already changed the state to + // streaming response. this->set_state_(new_state, new_state); } break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index f3ea669e15..76b076a366 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -244,6 +244,12 @@ class VoiceAssistant : public Component { void signal_stop_(); void start_playback_timeout_(); + // Drains the exposed microphone audio and sends it to Home Assistant over the API in one loop() pass. + void stream_api_audio_(); + // Handles a pass where at least one configured channel has no audio exposed, timing out a channel that + // stalls. See audio_channel_stall_start_. + void handle_channel_stall_(size_t available, size_t available2); + std::unique_ptr socket_ = nullptr; struct sockaddr_storage dest_addr_; @@ -315,6 +321,13 @@ class VoiceAssistant : public Component { std::weak_ptr ring_buffer_; std::weak_ptr ring_buffer2_; + // When streaming multiple channels, the send loop holds an exposed chunk on one channel until the other + // channel also has audio so the channels are always sent together (an empty payload looks like + // end-of-stream to Home Assistant). Home Assistant has no stream timeout, so a channel that stops + // producing entirely would hang streaming forever. This records when such an imbalance began so a + // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. + uint32_t audio_channel_stall_start_{0}; + bool use_wake_word_; uint8_t noise_suppression_level_; uint8_t auto_gain_; From bac62cb7dec73d5e3311d16d607fb1485e4e9584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:06 -0500 Subject: [PATCH 149/282] [core] Add cv.sensitive marker for schema-level sensitive fields (#16673) --- esphome/config_validation.py | 47 +++++++++++++++ script/build_language_schema.py | 39 +++++++++++- tests/script/test_build_language_schema.py | 69 +++++++++++++++++++++- tests/unit_tests/test_config_validation.py | 43 ++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ca1fd8f5d4..1d5e27c9ae 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -487,6 +487,53 @@ def string_strict(value): ) +# Substring fallbacks for fields whose validator isn't explicitly wrapped in +# ``cv.sensitive``. Frontends and dump tooling should prefer the explicit +# marker; this list exists so we still mask obvious leaks in unmigrated or +# third-party schemas. Kept here as the single source of truth. +SENSITIVE_KEY_FRAGMENTS: frozenset[str] = frozenset( + { + "password", + "passcode", + "secret", + "token", + "api_key", + "apikey", + "psk", + } +) + + +class SensitiveValidator: + """Marker wrapper that flags a field as containing sensitive data (passwords, + encryption keys, PSKs, tokens). Frontends and dump tooling detect this marker + to mask the value; validation behavior is delegated to the inner validator. + """ + + def __init__(self, inner: Callable[[typing.Any], typing.Any]) -> None: + self.inner = inner + + def __call__(self, value: typing.Any) -> typing.Any: + return self.inner(value) + + def __repr__(self) -> str: + # Mirror the inner validator's repr so ``build_language_schema``'s + # ``known_schemas``/``extended_schemas`` dedup (keyed on ``repr(schema)``) + # treats two wrappers around the same inner as identical, and so + # voluptuous error messages stay readable. + return repr(self.inner) + + +def sensitive( + inner: Callable[[typing.Any], typing.Any] = string, +) -> SensitiveValidator: + """Mark a field as sensitive so that frontends mask it and dump tooling redacts it. + + Validation behavior is identical to ``inner`` (defaults to ``cv.string``). + """ + return SensitiveValidator(inner) + + def icon(value): """Validate that a given config value is a valid icon.""" from esphome.core.config import ICON_MAX_LENGTH diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 9dff70af3c..6e4000e06e 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -39,7 +39,11 @@ parser.add_argument( ) parser.add_argument("--check", action="store_true", help="Check only for CI") -args = parser.parse_args() +# Module-level ``Namespace`` so helper functions can reference ``args`` +# without threading it through every call. ``main()`` fills it via +# ``parser.parse_args(namespace=args)``; tests import this module without +# invoking ``main()`` and rely on the defaults below. +args = argparse.Namespace(output_path=".", check=False) DUMP_RAW = False DUMP_UNKNOWN = False @@ -850,6 +854,12 @@ def convert(schema, config_var, path): convert(ext, config_var, f"{path}/ext{idx}") return + if isinstance(schema, cv.SensitiveValidator): + config_var["sensitive"] = True + config_var["sensitive_source"] = "explicit" + convert(schema.inner, config_var, f"{path}/sensitive") + return + if isinstance(schema, cv.All): i = 0 for inner in schema.validators: @@ -1125,6 +1135,25 @@ def convert_keys(converted, schema, path): # Do value convert(v, result, path + f"/{str(k)}") + + # Heuristic fallback when the field's validator wasn't explicitly + # wrapped in ``cv.sensitive``. Only applies to string-typed leaves so + # we don't mark unrelated nested schemas. ``sensitive_source`` lets + # consumers distinguish explicit markers from heuristic matches. Pull + # the field name from ``k.schema`` (voluptuous's stored key) rather + # than ``str(k)`` so we don't depend on the marker's ``__str__`` + # representation. + if ( + "sensitive" not in result + and result.get(S_TYPE) == "string" + and isinstance(k, (cv.Required, cv.Optional, cv.Inclusive, cv.Exclusive)) + and isinstance(k.schema, str) + ): + key_lower = k.schema.lower() + if any(frag in key_lower for frag in cv.SENSITIVE_KEY_FRAGMENTS): + result["sensitive"] = True + result["sensitive_source"] = "heuristic" + if "schema" not in converted: converted[S_TYPE] = "schema" converted["schema"] = {S_CONFIG_VARS: {}} @@ -1142,4 +1171,10 @@ def convert_keys(converted, schema, path): config_vars["string"] = config_vars.pop(key) -build_schema() +def main() -> None: + parser.parse_args(namespace=args) + build_schema() + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 59b8c7484b..dd1d88e74c 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,8 +3,11 @@ from __future__ import annotations import ast +import importlib.util from pathlib import Path +from esphome import config_validation as cv + SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "script" @@ -12,10 +15,16 @@ SCRIPT_PATH = ( ) +def _load_script_module(): + spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _extract_sort_obj(): - # build_language_schema.py runs argparse, loads every component, and - # calls build_schema() at import time, so a plain import isn't viable - # in a unit test. Pull just the pure helper out via AST instead. + # ``sort_obj`` is pure and self-contained; pulling it via AST avoids + # exercising the module-level component-loading state for these tests. tree = ast.parse(SCRIPT_PATH.read_text()) for node in tree.body: if isinstance(node, ast.FunctionDef) and node.name == "sort_obj": @@ -27,6 +36,7 @@ def _extract_sort_obj(): sort_obj = _extract_sort_obj() +_bls = _load_script_module() def test_sort_obj_sorts_dict_keys() -> None: @@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None: assert sort_obj(42) == 42 assert sort_obj(None) is None assert sort_obj(True) is True + + +def test_convert_emits_explicit_sensitive_marker() -> None: + config_var: dict = {} + _bls.convert(cv.sensitive(cv.string), config_var, "/test") + + assert config_var["sensitive"] is True + assert config_var["sensitive_source"] == "explicit" + assert config_var["type"] == "string" + + +def test_convert_keys_emits_heuristic_sensitive_marker() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "heuristic" + assert entry["type"] == "string" + + +def test_convert_keys_explicit_beats_heuristic() -> None: + # Key name matches a fragment but the validator is explicitly wrapped; + # the explicit branch should win and emit ``sensitive_source: explicit``. + converted: dict = {} + _bls.convert_keys( + converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root" + ) + + entry = converted["schema"]["config_vars"]["password"] + assert entry["sensitive"] is True + assert entry["sensitive_source"] == "explicit" + + +def test_convert_keys_no_heuristic_for_non_string_leaves() -> None: + # Even though the key contains a fragment, a non-string leaf must not + # be flagged. Prevents false positives on unrelated fields whose name + # happens to embed a substring like "token". + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root") + + entry = converted["schema"]["config_vars"]["password"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry + + +def test_convert_keys_no_marker_for_non_sensitive_field() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root") + + entry = converted["schema"]["config_vars"]["hostname"] + assert "sensitive" not in entry + assert "sensitive_source" not in entry diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index fd6c0e95f2..2c34cbfb07 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -127,6 +127,49 @@ def test_string_string__invalid(value): config_validation.string_strict(value) +def test_sensitive__default_delegates_to_string() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator.inner is config_validation.string + assert validator("hunter2") == "hunter2" + assert validator(42) == "42" + + +def test_sensitive__custom_inner_delegates_validation() -> None: + validator = config_validation.sensitive(config_validation.string_strict) + + assert validator.inner is config_validation.string_strict + assert validator("abc") == "abc" + with pytest.raises(Invalid, match="Must be string, got"): + validator(123) + + +def test_sensitive__is_detectable_via_isinstance() -> None: + validator = config_validation.sensitive() + + assert isinstance(validator, config_validation.SensitiveValidator) + + +def test_sensitive__repr_mirrors_inner() -> None: + # The schema dump dedups on ``repr(schema)``; mirroring the inner + # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers + # interchangeable for that purpose and avoids leaking the wrapper as + # noise in voluptuous error messages. + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.string + ) + assert repr(config_validation.sensitive(config_validation.string)) == repr( + config_validation.sensitive(config_validation.string) + ) + + +def test_sensitive_key_fragments__covers_common_terms() -> None: + assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): + assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + + @given( builds( lambda v: "mdi:" + v, From 96816e24916192a36daf7119b8fca81836067476 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:38 -0500 Subject: [PATCH 150/282] [core] Enable ruff DTZ (flake8-datetimez) lint family (#16660) --- esphome/__main__.py | 2 +- esphome/components/api/client.py | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 5 ++++- esphome/config_validation.py | 4 +++- esphome/external_files.py | 9 ++++++--- esphome/git.py | 8 ++++---- esphome/mqtt.py | 6 +++--- esphome/storage_json.py | 5 ++++- pyproject.toml | 1 + tests/unit_tests/test_external_files.py | 4 ++-- tests/unit_tests/test_git.py | 18 +++++++++--------- tests/unit_tests/test_storage_json.py | 4 ++-- 12 files changed, 40 insertions(+), 28 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5f281ce832..dd97c6eee9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -639,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue - time_ = datetime.now() + time_ = datetime.now().astimezone() milliseconds = time_.microsecond // 1000 time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 327973a605..44edc035f9 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -119,7 +119,7 @@ async def async_run_logs( def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" - time_ = datetime.now() + time_ = datetime.now().astimezone() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") nanoseconds = time_.microsecond // 1000 diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index aa16bbef53..39ecadfddf 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -161,7 +161,10 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_set_string(basic_attrs.mf_name, "esphome"), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") + basic_attrs.date_code, + # Local build time, matching the esp32 implementation + # (App.get_build_time() in C++). + datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1d5e27c9ae..f826b254ac 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1183,7 +1183,9 @@ def date_time(date: bool, time: bool): format += "%p" try: - date_obj = datetime.strptime(value, format) + # The generated format never includes %z/%Z, so this parses a + # naive wall-clock date/time by design. + date_obj = datetime.strptime(value, format) # noqa: DTZ007 except ValueError as err: raise Invalid(f"Invalid {exc_message}: {err}") from err diff --git a/esphome/external_files.py b/esphome/external_files.py index dfabc54f47..4e73c8dc21 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime import logging import os from pathlib import Path +import time import requests @@ -141,9 +142,11 @@ def has_remote_file_changed( def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool: if file_path.exists(): - creation_time = file_path.stat().st_ctime - current_time = datetime.now().timestamp() - return current_time - creation_time <= refresh.total_seconds + # st_mtime, not st_ctime: ctime is inode-change time on POSIX + # (bumped by chmod/chown/rename) so a metadata touch would make + # the file look fresh. + modification_time = file_path.stat().st_mtime + return time.time() - modification_time <= refresh.total_seconds return False diff --git a/esphome/git.py b/esphome/git.py index 094a6dae19..744ce35ef6 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,12 +1,12 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime import hashlib import logging from pathlib import Path import re import subprocess import sys +import time import urllib.parse import esphome.config_validation as cv @@ -247,11 +247,11 @@ def clone_or_update( return repo_dir, None file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") - # On first clone, FETCH_HEAD does not exists + # On first clone, FETCH_HEAD does not exist if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") - age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) - if refresh is None or age.total_seconds() > refresh.total_seconds: + age_seconds = time.time() - file_timestamp.stat().st_mtime + if refresh is None or age_seconds > refresh.total_seconds: # Try to update the repository, recovering from broken state if needed old_sha: str | None = None try: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index d6bde0cbfd..c6a7a7558b 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -139,7 +139,7 @@ def show_discover(config, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -184,7 +184,7 @@ def get_esphome_device_ip( def on_message(client, userdata, msg): nonlocal dev_ip - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload @@ -253,7 +253,7 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): _LOGGER.info("Starting log output from %s", topic) def on_message(client, userdata, msg): - time_ = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") message = time_ + payload safe_print(message) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 7f8885ba5f..04f5881465 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -338,7 +338,10 @@ class EsphomeStorageJSON: @property def last_update_check(self) -> datetime | None: try: - return datetime.strptime(self.last_update_check_str, "%Y-%m-%dT%H:%M:%S") + # Stored format is naive ISO without %z; preserved for backward compat. + return datetime.strptime( # noqa: DTZ007 + self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" + ) except Exception: # pylint: disable=broad-except return None diff --git a/pyproject.toml b/pyproject.toml index 6572078746..d92c7ba894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez "E", # pycodestyle "EXE", # flake8-executable "F", # pyflakes/autoflake diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 64ef149581..16cee9564f 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -120,7 +120,7 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None: old_time = time.time() - 7200 mock_stat = MagicMock() - mock_stat.st_ctime = old_time + mock_stat.st_mtime = old_time with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=3600) @@ -147,7 +147,7 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: # Mock stat to return a time 10 seconds ago mock_stat = MagicMock() - mock_stat.st_ctime = time.time() - 10 + mock_stat.st_mtime = time.time() - 10 with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=0) result = external_files.is_file_recent(test_file, refresh) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 690c47c183..62d2344069 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,8 +1,8 @@ """Tests for git.py module.""" -from datetime import datetime, timedelta import os from pathlib import Path +import time from typing import Any from unittest.mock import Mock, patch @@ -34,9 +34,9 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: # Create FETCH_HEAD file with old timestamp fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=days_old) + old_time = time.time() - days_old * 86400 fetch_head.touch() - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) def _get_git_command_type(cmd: list[str]) -> str | None: @@ -285,10 +285,10 @@ def test_clone_or_update_with_refresh_updates_old_repo( # Create FETCH_HEAD file with old timestamp (2 days ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - old_time = datetime.now() - timedelta(days=2) + old_time = time.time() - 2 * 86400 fetch_head.touch() # Create the file # Set modification time to 2 days ago - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + os.utime(fetch_head, (old_time, old_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse @@ -333,10 +333,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Create FETCH_HEAD file with recent timestamp (1 hour ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(hours=1) + recent_time = time.time() - 3600 fetch_head.touch() # Create the file # Set modification time to 1 hour ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) @@ -409,10 +409,10 @@ def test_clone_or_update_with_none_refresh_always_updates( # Create FETCH_HEAD file with very recent timestamp (1 second ago) fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - recent_time = datetime.now() - timedelta(seconds=1) + recent_time = time.time() - 1 fetch_head.touch() # Create the file # Set modification time to 1 second ago - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + os.utime(fetch_head, (recent_time, recent_time)) # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index ea37492cf4..b3f8a05605 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -576,8 +576,8 @@ def test_esphome_storage_json_last_update_check_property() -> None: assert result.hour == 10 assert result.minute == 30 - # Test setter - new_date = datetime(2024, 2, 20, 15, 45, 30) + # Test setter — naive datetime matches the storage round-trip format. + new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001 storage.last_update_check = new_date assert storage.last_update_check_str == "2024-02-20T15:45:30" From 52ead52ef29fd01feabf6c6826b10b5f4fda200c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 15:29:54 -0500 Subject: [PATCH 151/282] [core] Enable ruff PGH (pygrep-hooks) lint family (#16651) --- esphome/components/esp32/__init__.py | 4 ++-- esphome/components/host/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 2 +- .../libretiny/generate_components.py | 18 +++++++++++++----- esphome/components/light/__init__.py | 2 +- esphome/components/logger/__init__.py | 2 +- esphome/components/lvgl/helpers.py | 2 -- esphome/components/nrf52/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 2 +- pyproject.toml | 1 + 10 files changed, 22 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e3bff8f934..7b94a26f54 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -56,7 +56,7 @@ from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS -from .const import ( # noqa +from .const import ( KEY_ARDUINO_LIBRARIES, KEY_BOARD, KEY_COMPONENTS, @@ -86,7 +86,7 @@ from .const import ( # noqa ) # force import gpio to register pin schema -from .gpio import esp32_pin_to_code # noqa +from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 8adbfb02ec..50deb1acf6 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -14,7 +14,7 @@ from esphome.core import CORE from .const import KEY_HOST # force import gpio to register pin schema -from .gpio import host_pin_to_code # noqa +from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index d1f1042501..afe0360c22 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON -from . import gpio # noqa +from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, CONF_GPIO_RECOVER, diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index d5437895a6..6ca16f277f 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -1,7 +1,7 @@ # Copyright (c) Kuba Szczodrzyński 2023-06-01. # pylint: skip-file -# flake8: noqa +# ruff: noqa: C408, I001 import json import re @@ -313,8 +313,12 @@ def write_const( # build component constants comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components) # replace the 2nd regex group only - repl = lambda m: m.group(1) + comp_str + m.group(3) - code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + comp_regex, + lambda m: m.group(1) + comp_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # regex for finding the family list block fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)" @@ -337,8 +341,12 @@ def write_const( ] var_str = "\n".join(fam_lines) # replace the 2nd regex group only - repl = lambda m: m.group(1) + var_str + m.group(3) - code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + fam_regex, + lambda m: m.group(1) + var_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # format with black code = format_str(code, mode=FileMode()) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 68d9f85af2..7c4d7ed431 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -58,7 +58,7 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) -from .types import ( # noqa +from .types import ( # noqa: F401 AddressableLight, AddressableLightState, ColorMode, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c6c440564a..5f160352cc 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -514,7 +514,7 @@ def validate_printf(value): (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size [cCdiouxXeEfgGaAnpsSZ] # type ) - """ # noqa + """ matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index 6f70a1e3bd..3da8643308 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -6,7 +6,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -# noqa f_regex = re.compile( r""" ( # start of capture group 1 @@ -20,7 +19,6 @@ f_regex = re.compile( """, flags=re.VERBOSE, ) -# noqa c_regex = re.compile( r""" ( # start of capture group 1 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2aba208af7..4ba1ab5d4d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -65,7 +65,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import nrf52_pin_to_code # noqa +from .gpio import nrf52_pin_to_code # noqa: F401 CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr", "preferences"] diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 830c961476..6ec0ee08b8 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -50,7 +50,7 @@ from .const import ( ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa +from .gpio import rp2040_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] diff --git a/pyproject.toml b/pyproject.toml index d92c7ba894..d2f30ea3d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ select = [ "LOG", # flake8-logging "NPY", # numpy-specific rules "PERF", # performance + "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PTH", # flake8-use-pathlib From 62b3b1cc7509e2b7a1ce5f26ebd08abe4fc8b495 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 May 2026 06:00:08 +0930 Subject: [PATCH 152/282] [lvgl] Support `rounded` property for meter arcs (#16669) --- esphome/components/lvgl/widgets/meter.py | 3 ++- tests/components/lvgl/lvgl-package.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 62ea14bdda..e2407fad5a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema( cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Optional(CONF_ROUNDED, default=False): cv.boolean, } ).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) @@ -417,7 +418,7 @@ class MeterType(WidgetType): "arc_width": v[CONF_WIDTH], "arc_color": v[CONF_COLOR], "arc_opa": v[CONF_OPA], - "arc_rounded": v.get("arc_rounded", False), + "arc_rounded": v[CONF_ROUNDED], } if CONF_R_MOD in v: get_warnings().add( diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 0f4b961297..7af058e6b8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1313,6 +1313,7 @@ lvgl: width: 6 start_value: 0 end_value: 360 + rounded: true - id: page3 layout: Horizontal pad_all: 6px From 4d908798bcf41b8cbe151a2d5c1aa753d7aaaa40 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:45:50 -0400 Subject: [PATCH 153/282] [core] Remove deprecated custom_components folder loading (#16679) --- esphome/config.py | 1 - esphome/loader.py | 13 ------------- esphome/writer.py | 4 ++-- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 79d0d2b02b..9da39a387b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1005,7 +1005,6 @@ def validate_config( CORE.skip_external_update = skip_external_update loader.clear_component_meta_finders() - loader.install_custom_components_meta_finder() # 0. Load packages if CONF_PACKAGES in config: diff --git a/esphome/loader.py b/esphome/loader.py index c57c09274e..8823d82fc1 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -12,7 +12,6 @@ from types import ModuleType from typing import TYPE_CHECKING, Any from esphome.const import SOURCE_FILE_EXTENSIONS -from esphome.core import CORE from esphome.types import ConfigType if TYPE_CHECKING: @@ -206,18 +205,6 @@ def install_meta_finder( sys.meta_path.insert(0, ComponentMetaFinder(components_path, allowed_components)) -def install_custom_components_meta_finder(): - # Remove before 2026.6.0 - custom_components_dir = (Path(CORE.config_dir) / "custom_components").resolve() - if custom_components_dir.is_dir() and any(custom_components_dir.iterdir()): - _LOGGER.warning( - "The 'custom_components' folder is deprecated and will be removed in 2026.6.0. " - "Please use 'external_components' instead. " - "See https://esphome.io/components/external_components.html for more information." - ) - install_meta_finder(custom_components_dir) - - def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] diff --git a/esphome/writer.py b/esphome/writer.py index ab014c5daa..ef7cbf5ac4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -200,8 +200,8 @@ ESPHome automatically populates the build directory, and any changes to this directory will be removed the next time esphome is run. -For modifying esphome's core files, please use a development esphome install, -the custom_components folder or the external_components feature. +For modifying esphome's core files, please use a development esphome install +or the external_components feature. """ From b71d445e7963303f1ab76b6dd9ccdebf609115b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:46:45 -0400 Subject: [PATCH 154/282] [core] Remove deprecated const char* mark_failed/status_set_error (#16680) --- esphome/core/component.cpp | 33 +++++++-------------------------- esphome/core/component.h | 17 ----------------- 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e33652482e..2d80301897 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -32,10 +32,7 @@ static const char *const TAG = "component"; namespace { struct ComponentErrorMessage { const Component *component; - const char *message; - // Track if message is flash pointer (needs LOG_STR_ARG) or RAM pointer - // Remove before 2026.6.0 when deprecated const char* API is removed - bool is_flash_ptr; + const LogString *message; }; #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -56,9 +53,8 @@ std::vector *setup_priority_overrides = nullptr; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::vector *component_error_messages = nullptr; -// Helper to store error messages - reduces duplication between deprecated and new API -// Remove before 2026.6.0 when deprecated const char* API is removed -void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { +// Helper to store error messages +void store_component_error_message(const Component *component, const LogString *message) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { component_error_messages = new std::vector(); @@ -67,12 +63,11 @@ void store_component_error_message(const Component *component, const char *messa for (auto &entry : *component_error_messages) { if (entry.component == component) { entry.message = message; - entry.is_flash_ptr = is_flash_ptr; return; } } // Add new error message - component_error_messages->emplace_back(ComponentErrorMessage{component, message, is_flash_ptr}); + component_error_messages->emplace_back(ComponentErrorMessage{component, message}); } } // namespace @@ -209,21 +204,17 @@ void Component::call_dump_config_() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector - const char *error_msg = nullptr; - bool is_flash_ptr = false; + const LogString *error_msg = nullptr; if (component_error_messages) { for (const auto &entry : *component_error_messages) { if (entry.component == this) { error_msg = entry.message; - is_flash_ptr = entry.is_flash_ptr; break; } } } - // Log with appropriate format based on pointer type ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), - error_msg ? (is_flash_ptr ? LOG_STR_ARG((const LogString *) error_msg) : error_msg) - : LOG_STR_LITERAL("unspecified")); + error_msg ? LOG_STR_ARG(error_msg) : LOG_STR_LITERAL("unspecified")); } } @@ -390,23 +381,13 @@ void Component::status_set_warning(const LogString *message) { message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } -void Component::status_set_error(const char *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? message : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { - store_component_error_message(this, message, false); - } -} void Component::status_set_error(const LogString *message) { if (!this->set_status_flag_(STATUS_LED_ERROR)) return; ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { - // Store the LogString pointer directly (safe because LogString is always in flash/static memory) - store_component_error_message(this, LOG_STR_ARG(message), true); + store_component_error_message(this, message); } } void Component::status_clear_warning_slow_path_() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 5baf795ca6..ff10f1a8f1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -220,18 +220,6 @@ class Component { */ void mark_failed(); - // Remove before 2026.6.0 - ESPDEPRECATED("Use mark_failed(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void mark_failed(const char *message) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->status_set_error(message); -#pragma GCC diagnostic pop - this->mark_failed(); - } - void mark_failed(const LogString *message) { this->status_set_error(message); this->mark_failed(); @@ -296,11 +284,6 @@ class Component { void status_set_warning(const LogString *message); void status_set_error(); // Set error flag without message - // Remove before 2026.6.0 - ESPDEPRECATED("Use status_set_error(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " - "strings. Will stop working in 2026.6.0", - "2025.12.0") - void status_set_error(const char *message); void status_set_error(const LogString *message); void status_clear_warning() { From 171ded35a58728ef48cb65198bcd457ea92f1fa9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:16 -0400 Subject: [PATCH 155/282] [core] Remove cv.only_with_esp_idf and CORE.using_esp_idf (#16681) --- esphome/config_validation.py | 10 ---------- esphome/core/__init__.py | 9 --------- script/ci-custom.py | 13 ------------- 3 files changed, 32 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f826b254ac..2f09fdc105 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -857,16 +857,6 @@ only_on_rp2040 = only_on(PLATFORM_RP2040) only_with_arduino = only_with_framework(Framework.ARDUINO) -def only_with_esp_idf(obj): - """Deprecated: use only_on_esp32 instead.""" - _LOGGER.warning( - "cv.only_with_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use cv.only_on_esp32 and/or cv.only_with_arduino instead." - ) - return only_with_framework(Framework.ESP_IDF)(obj) - - # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 def has_at_least_one_key(*keys): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 182be38b18..df8fd0a756 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -859,15 +859,6 @@ class EsphomeCore: def using_arduino(self): return self.target_framework == "arduino" - @property - def using_esp_idf(self): - _LOGGER.warning( - "CORE.using_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - "Use CORE.is_esp32 and/or CORE.using_arduino instead." - ) - return self.target_framework == "esp-idf" - @property def using_toolchain_esp_idf(self): return self.toolchain == Toolchain.ESP_IDF diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ac13e18f7..78ff6cf781 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -693,19 +693,6 @@ def lint_esphome_h(fname, line, col, content): ) -@lint_content_find_check( - "CORE.using_esp_idf", - include=py_include, - exclude=["esphome/core/__init__.py", "script/ci-custom.py"], -) -def lint_using_esp_idf_deprecated(fname, line, col, content): - return ( - f"{highlight('CORE.using_esp_idf')} is deprecated and will change behavior in 2026.6. " - "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. " - f"Please use {highlight('CORE.is_esp32')} and/or {highlight('CORE.using_arduino')} instead." - ) - - @lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: From fb0b73980bf188d5530e62c02dda451560164c2b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:47:40 -0400 Subject: [PATCH 156/282] [wifi] Default ESP8266 min_auth_mode to WPA2 (#16682) --- esphome/components/wifi/__init__.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f9cb391442..e5e57cc97d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -326,23 +326,9 @@ def validate_variant(_): def _apply_min_auth_mode_default(config): - """Apply platform-specific default for min_auth_mode and warn ESP8266 users.""" - # Only apply defaults for platforms that support min_auth_mode + """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): - if CORE.is_esp8266: - _LOGGER.warning( - "The minimum WiFi authentication mode (wifi -> min_auth_mode) is not set. " - "This controls the weakest encryption your device will accept when connecting to WiFi. " - "Currently defaults to WPA (less secure), but will change to WPA2 (more secure) in 2026.6.0. " - "WPA uses TKIP encryption which has known security vulnerabilities and should be avoided. " - "WPA2 uses AES encryption which is significantly more secure. " - "To silence this warning, explicitly set min_auth_mode under 'wifi:'. " - "If your router supports WPA2 or WPA3, set 'min_auth_mode: WPA2'. " - "If your router only supports WPA, set 'min_auth_mode: WPA'." - ) - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA") - elif CORE.is_esp32: - config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") return config From eb1196c6b22ffe8da8c273d52bba546489ff0d04 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:48:17 -0400 Subject: [PATCH 157/282] [nfc] Remove deprecated heap-allocating format helpers (#16684) --- esphome/components/nfc/nfc.cpp | 11 ----------- esphome/components/nfc/nfc.h | 7 ------- 2 files changed, 18 deletions(-) diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 99e476dbdf..76a391f1de 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -15,17 +15,6 @@ char *format_bytes_to(char *buffer, std::span bytes) { return format_hex_pretty_to(buffer, FORMAT_BYTES_BUFFER_SIZE, bytes.data(), bytes.size(), ' '); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -// Deprecated wrappers intentionally use heap-allocating version for backward compatibility -std::string format_uid(std::span uid) { - return format_hex_pretty(uid.data(), uid.size(), '-', false); // NOLINT -} -std::string format_bytes(std::span bytes) { - return format_hex_pretty(bytes.data(), bytes.size(), ' ', false); // NOLINT -} -#pragma GCC diagnostic pop - uint8_t guess_tag_type(uint8_t uid_length) { if (uid_length == 4) { return TAG_TYPE_MIFARE_CLASSIC; diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 42ef993913..36b27ce5f6 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -63,13 +63,6 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; /// Format bytes to buffer with ' ' separator (e.g., "04 11 22 33"). Returns buffer for inline use. char *format_bytes_to(char *buffer, std::span bytes); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_uid_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_uid(std::span uid); -// Remove before 2026.6.0 -ESPDEPRECATED("Use format_bytes_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0") -std::string format_bytes(std::span bytes); - uint8_t guess_tag_type(uint8_t uid_length); int8_t get_mifare_classic_ndef_start_index(std::vector &data); bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); From 6c4a8a324515f9d6dc3280ed4e82993022c5138b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:49:44 -0400 Subject: [PATCH 158/282] [dsmr] Force BearSSL on ESP8266 to avoid mbedtls link failure (#16686) --- esphome/components/dsmr/dsmr.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 626a389c1f..e55db9f976 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -16,9 +16,14 @@ #include #include +// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can +// still be in scope when a sibling component (e.g. wireguard) pulls in +// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the +// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to +// avoid that linker error. #if __has_include() #include -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() #if __has_include() #include #endif @@ -33,7 +38,7 @@ namespace esphome::dsmr { #if __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #else using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; From f728cb437377ae32c9c2b0b99ca9c55d6c8d9925 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 18:50:20 -0400 Subject: [PATCH 159/282] [core] Remove deprecated seq/gens templates (#16685) --- esphome/core/automation.h | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 468ea3b382..ea522a4d2d 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -13,27 +13,6 @@ namespace esphome { -// C++20 std::index_sequence is now used for tuple unpacking -// Legacy seq<>/gens<> pattern deprecated but kept for backwards compatibility -// https://stackoverflow.com/questions/7858817/unpacking-a-tuple-to-call-a-matching-function-pointer/7858971#7858971 -// Remove before 2026.6.0 -// NOLINTBEGIN(readability-identifier-naming) -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif - -template struct ESPDEPRECATED("Use std::index_sequence instead. Removed in 2026.6.0", "2025.12.0") seq {}; -template -struct ESPDEPRECATED("Use std::make_index_sequence instead. Removed in 2026.6.0", "2025.12.0") gens - : gens {}; -template struct gens<0, S...> { using type = seq; }; - -#if defined(__GNUC__) || defined(__clang__) -#pragma GCC diagnostic pop -#endif -// NOLINTEND(readability-identifier-naming) - /// Function-pointer-only templatable storage (4 bytes on 32-bit). /// Used by the TEMPLATABLE_VALUE macro for codegen-managed fields. /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. From e174c44b283f0ed6f3748f0c796b9ee62922bdb4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:15:25 -0400 Subject: [PATCH 160/282] [neopixelbus] Deprecate on ESP32 (#16676) --- esphome/components/neopixelbus/light.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 943fd141f6..2e18688af0 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import light @@ -22,6 +24,7 @@ from esphome.const import ( Framework, ) from esphome.core import CORE +from esphome.types import ConfigType from ._methods import ( METHOD_BIT_BANG, @@ -34,6 +37,8 @@ from ._methods import ( ) from .const import CHIP_TYPES, CONF_ASYNC, CONF_BUS, ONE_WIRE_CHIPS +_LOGGER = logging.getLogger(__name__) + neopixelbus_ns = cg.esphome_ns.namespace("neopixelbus") NeoPixelBusLightOutputBase = neopixelbus_ns.class_( "NeoPixelBusLightOutputBase", light.AddressableLight @@ -134,6 +139,17 @@ def _validate(config): return config +def _warn_esp32_deprecated(config: ConfigType) -> ConfigType: + if CORE.is_esp32: + _LOGGER.warning( + "'neopixelbus' on ESP32 is deprecated. The upstream library " + "(makuna/NeoPixelBus) is no longer actively maintained. Migrate " + "to 'esp32_rmt_led_strip'. Removal is targeted for 2027.1 but " + "may happen sooner once ESPHome moves to ESP-IDF 6." + ) + return config + + def _validate_method(value): if value is None: # default method is determined afterwards because it depends on the chip type chosen @@ -195,6 +211,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), _choose_default_method, _validate, + _warn_esp32_deprecated, ) From a6ef67aa65892d7312ba7191bbe41742b2d0c80a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:52 -0400 Subject: [PATCH 161/282] [text_sensor] Remove deprecated public raw_state member (#16683) --- .../components/text_sensor/text_sensor.cpp | 19 ++++++------------- esphome/components/text_sensor/text_sensor.h | 10 ++-------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 31543117b8..d2483619a6 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -39,16 +39,13 @@ void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER } else { // Has filters: need separate raw storage -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" // Only assign if changed to avoid heap allocation - if (len != this->raw_state.size() || memcmp(state, this->raw_state.data(), len) != 0) { - this->raw_state.assign(state, len); + if (len != this->raw_state_.size() || memcmp(state, this->raw_state_.data(), len) != 0) { + this->raw_state_.assign(state, len); } - this->raw_callback_.call(this->raw_state); - ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state.c_str()); - this->filter_list_->input(this->raw_state); -#pragma GCC diagnostic pop + this->raw_callback_.call(this->raw_state_); + ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state_.c_str()); + this->filter_list_->input(this->raw_state_); } #endif } @@ -89,11 +86,7 @@ const std::string &TextSensor::get_state() const { return this->state; } const std::string &TextSensor::get_raw_state() const { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ != nullptr) { - // Suppress deprecation warning - get_raw_state() is the replacement API -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop + return this->raw_state_; } #endif return this->state; // No filters, raw == filtered diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 3f69e91c8d..aa48781f41 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,19 +29,12 @@ class TextSensor : public EntityBase { public: std::string 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.6.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") - std::string raw_state; - TextSensor() = default; ~TextSensor() = default; -#pragma GCC diagnostic pop /// Getter-syntax for .state. const std::string &get_state() const; - /// Getter-syntax for .raw_state + /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; void publish_state(const std::string &state); @@ -84,6 +77,7 @@ class TextSensor : public EntityBase { /// Notify frontend that state has changed (assumes this->state is already set) void notify_frontend_(); #ifdef USE_TEXT_SENSOR_FILTER + std::string raw_state_; ///< Backing storage for the raw (pre-filter) value. Only used when a filter is attached. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. From 91ead4ff543e6d46f5e6aa987e7f4206e6de868a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 19:16:47 -0500 Subject: [PATCH 162/282] [core] Mark canonical sensitive fields with cv.sensitive (#16677) --- esphome/components/api/__init__.py | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/http_request/ota/__init__.py | 2 +- esphome/components/mqtt/__init__.py | 2 +- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/wifi/__init__.py | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ca74483a2b..932702d47a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation( ENCRYPTION_SCHEMA = cv.Schema( { - cv.Optional(CONF_KEY): validate_encryption_key, + cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), } ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f7793b1493..66a33e1935 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All( host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(), cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index fb59e51943..1bb54599dc 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( cv.Optional(CONF_MD5): cv.templatable( cv.All(cv.string, cv.Length(min=32, max=32)) ), - cv.Optional(CONF_PASSWORD): cv.templatable(cv.string), + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)), cv.Optional(CONF_USERNAME): cv.templatable(cv.string), cv.Required(CONF_URL): cv.templatable(cv.url), } diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index cb6b9d144f..86bba11a60 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_PORT, default=1883): cv.port, cv.Optional(CONF_USERNAME, default=""): cv.string, - cv.Optional(CONF_PASSWORD, default=""): cv.string, + cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(), cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, cv.Optional(CONF_CLIENT_ID): cv.string, cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 99a9b7518c..fd380a38dd 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,8 +193,8 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_USERNAME): cv.All( cv.string_strict, cv.Length(min=1) ), - cv.Required(CONF_PASSWORD): cv.All( - cv.string_strict, cv.Length(min=1) + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) ), } ), diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index e5e57cc97d..4e7dcc82e5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -251,7 +251,7 @@ EAP_AUTH_SCHEMA = cv.All( { cv.Optional(CONF_IDENTITY): cv.string_strict, cv.Optional(CONF_USERNAME): cv.string_strict, - cv.Optional(CONF_PASSWORD): cv.string_strict, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string_strict), cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, cv.SplitDefault(CONF_TTLS_PHASE_2, esp32="mschapv2"): cv.All( cv.enum(TTLS_PHASE_2), cv.only_on_esp32 @@ -272,7 +272,7 @@ WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, } @@ -435,7 +435,7 @@ CONFIG_SCHEMA = cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), cv.Optional(CONF_SSID): cv.ssid, - cv.Optional(CONF_PASSWORD): validate_password, + cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, @@ -851,7 +851,7 @@ async def final_step(): cv.Schema( { cv.Required(CONF_SSID): cv.templatable(cv.ssid), - cv.Required(CONF_PASSWORD): cv.templatable(validate_password), + cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( cv.positive_time_period_milliseconds From 87d0e24d194164e1988ba742cd75b32176cf6cf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:57:29 -0500 Subject: [PATCH 163/282] Bump aioesphomeapi from 45.2.2 to 45.3.1 (#16688) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45401a7995..14dddbb1aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.2.2 +aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 8d19c55be2325d0a1c2dd06898ad4ec19c7c6602 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:58:13 -0500 Subject: [PATCH 164/282] Bump pytest-asyncio from 1.3.0 to 1.4.0 (#16687) 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 102a9cae6e..aad1da0807 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ pre-commit pytest==9.0.3 pytest-cov==7.1.0 pytest-mock==3.15.1 -pytest-asyncio==1.3.0 +pytest-asyncio==1.4.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 From 7463a15c7e4b5c897a8e210fdd43891e5465c8cf Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Wed, 27 May 2026 08:43:38 +0100 Subject: [PATCH 165/282] [network] Add Zephyr IPv6 networking support for nRF52 (#16336) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: tomaszduda23 --- esphome/components/e131/e131.h | 2 + esphome/components/mdns/__init__.py | 1 + esphome/components/mdns/mdns_zephyr.cpp | 17 ++++++++ esphome/components/network/__init__.py | 16 +++++++ esphome/components/network/ip_address.h | 42 +++++++++++++++++-- .../prometheus/prometheus_handler.h | 4 +- esphome/components/statsd/statsd.cpp | 2 +- esphome/components/statsd/statsd.h | 4 +- esphome/components/sx1509/sx1509.h | 10 ++--- esphome/components/tca9555/tca9555.h | 7 ++-- .../components/wake_on_lan/wake_on_lan.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.h | 4 +- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 4 +- esphome/core/defines.h | 2 +- .../network/test.nrf52-adafruit.yaml | 1 + .../components/network/test.nrf52-mcumgr.yaml | 1 + .../network/test.nrf52-xiao-ble.yaml | 1 + 18 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 esphome/components/mdns/mdns_zephyr.cpp create mode 100644 tests/components/network/test.nrf52-adafruit.yaml create mode 100644 tests/components/network/test.nrf52-mcumgr.yaml create mode 100644 tests/components/network/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index bfcb0ca7f8..6574037efb 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -52,6 +52,8 @@ class E131Component : public esphome::Component { if (!this->udp_.parsePacket()) return -1; return this->udp_.read(buf, len); +#else + return -1; #endif } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2b25cf243d..2de67542b2 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -280,5 +280,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "mdns_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/mdns/mdns_zephyr.cpp b/esphome/components/mdns/mdns_zephyr.cpp new file mode 100644 index 0000000000..0b2fd9e62b --- /dev/null +++ b/esphome/components/mdns/mdns_zephyr.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" +#if defined(USE_ZEPHYR) && defined(USE_MDNS) + +#include "mdns_component.h" +#include "esphome/core/log.h" + +namespace esphome::mdns { + +static const char *const TAG = "mdns.zephyr"; + +void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } + +void MDNSComponent::on_shutdown() {} + +} // namespace esphome::mdns + +#endif // USE_ZEPHYR && USE_MDNS diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 2818b8c93e..3bb14a05a7 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -4,6 +4,7 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed +from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -117,6 +118,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266=False, host=False, rp2040=False, + nrf52=True, ): cv.All( cv.boolean, cv.Any( @@ -127,6 +129,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, ), @@ -205,6 +208,19 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + if CORE.is_nrf52: + enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) + if not enable_ipv6: + _LOGGER.warning( + "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " + "Forcing CONFIG_NET_IPV6=y." + ) + config[CONF_ENABLE_IPV6] = True + zephyr_add_prj_conf("NETWORKING", True) + zephyr_add_prj_conf("NET_IPV6", True) + zephyr_add_prj_conf("NET_TCP", True) + zephyr_add_prj_conf("NET_UDP", True) + if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) if enable_ipv6: diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index c0e7b2886c..55bb2a1c89 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -5,13 +5,13 @@ #include #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/macros.h" #if defined(USE_ESP32) || defined(USE_LIBRETINY) || USE_ARDUINO_VERSION_CODE > VERSION_CODE(3, 0, 0) #include #endif - #if USE_ARDUINO #include #include @@ -24,6 +24,14 @@ using ip4_addr_t = in_addr; #define ipaddr_aton(x, y) inet_aton((x), (y)) #endif +#ifdef USE_ZEPHYR +#include +#include +#include +using ip_addr_t = struct in6_addr; +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; } +#endif + #if USE_ESP32_FRAMEWORK_ARDUINO #define arduino_ns Arduino_h #elif USE_LIBRETINY @@ -33,7 +41,6 @@ using ip4_addr_t = in_addr; #endif #ifdef USE_ESP32 -#include #include #endif @@ -52,7 +59,36 @@ inline void lowercase_ip_str(char *buf) { struct IPAddress { public: -#ifdef USE_HOST +#ifdef USE_ZEPHYR + IPAddress() { memset(&ip_addr_, 0, sizeof(ip_addr_)); } + IPAddress(const std::string &in_address) : ip_addr_{} { ipaddr_aton(in_address.c_str(), &ip_addr_); } + IPAddress(const struct in6_addr *other_ip) { ip_addr_ = *other_ip; } + IPAddress(const struct sockaddr_in6 *addr) { ip_addr_ = addr->sin6_addr; } + + operator struct in6_addr() const { return ip_addr_; } + + bool is_set() const { return !net_ipv6_is_addr_unspecified(&ip_addr_); } + bool is_ip4() const { return false; } + bool is_ip6() const { return this->is_set(); } + bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } + // 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 (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) + buf[0] = '\0'; + return buf; + } + bool operator==(const IPAddress &other) const { return net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } + +#elif defined(USE_HOST) IPAddress() { ip_addr_.s_addr = 0; } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 53326e9472..008081f586 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -219,4 +219,4 @@ class PrometheusHandler : public AsyncWebHandler, public Component { } // namespace esphome::prometheus -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 7086e462a7..2a56551255 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -2,7 +2,7 @@ #include "statsd.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::statsd { diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 349bffe6fb..77f3d797c5 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/components/socket/socket.h" #include "esphome/components/network/ip_address.h" @@ -83,4 +83,4 @@ class StatsdComponent : public PollingComponent { } // namespace esphome::statsd -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index f645ede754..35883eed5b 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,7 @@ class SX1509Component : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; + void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -62,10 +62,10 @@ class SX1509Component : public Component, void setup_led_driver(uint8_t pin); protected: - // Virtual methods from CachedGpioExpander - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from CachedGpioExpander — names come from base class + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) uint32_t clk_x_ = 2000000; uint8_t frequency_ = 0; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 7d37edad73..19773a0e93 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -27,9 +27,10 @@ class TCA9555Component : public Component, protected: static void IRAM_ATTR gpio_intr(TCA9555Component *arg); - bool digital_read_hw(uint8_t pin) override; - bool digital_read_cache(uint8_t pin) override; - void digital_write_hw(uint8_t pin, bool value) override; + // Virtual methods from GpioExpander base class — names come from base + bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) + bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) + void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) /// Mask for the pin mode - 1 means output, 0 means input uint16_t mode_mask_{0x00}; diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index fee6377965..a514a55d80 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,5 +1,5 @@ #include "wake_on_lan.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 48f8d00a66..84bc26e064 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -32,4 +32,4 @@ class WakeOnLanButton : public button::Button, public Component { } // namespace esphome::wake_on_lan -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 3e1baf34ba..ccfc04f674 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 2aa3ae215c..c7162c139a 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include #include @@ -145,4 +145,4 @@ class WebServerBase { }; } // namespace esphome::web_server_base -#endif +#endif // USE_NETWORK && !USE_ZEPHYR diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0229bc14fa..f536467e2f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -133,6 +133,7 @@ #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define USE_NETWORK #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START @@ -202,7 +203,6 @@ #define USE_SHA256 #define USE_MQTT #define USE_MQTT_COVER_JSON -#define USE_NETWORK #define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +network: diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..61889b0361 --- /dev/null +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +network: From 3cc875c40b0b0a0f4ccd1b6110f4d4bd16a02288 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 03:09:57 -0500 Subject: [PATCH 166/282] [core] Enable ruff BLE (flake8-blind-except) lint family (#16659) --- esphome/__main__.py | 2 +- esphome/async_thread.py | 2 +- esphome/compiled_config.py | 4 ++-- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/nrf52/__init__.py | 2 +- esphome/dashboard/web_server.py | 2 +- esphome/espidf/extra_script.py | 2 +- esphome/espidf/framework.py | 2 +- esphome/platformio/runner.py | 2 +- esphome/storage_json.py | 6 +++--- esphome/util.py | 4 ++-- esphome/vscode.py | 4 ++-- esphome/zeroconf.py | 2 +- pyproject.toml | 1 + script/analyze_component_buses.py | 6 +++--- script/build_helpers.py | 2 +- script/determine-jobs.py | 2 +- script/merge_component_configs.py | 2 +- script/stress_test_connect.py | 2 +- script/test_component_grouping.py | 2 +- tests/integration/test_syslog.py | 2 +- tests/integration/test_udp.py | 2 +- 23 files changed, 30 insertions(+), 29 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index dd97c6eee9..03f12c75d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1800,7 +1800,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ram_report = ram_analyzer.generate_report() print() print(ram_report) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("RAM strings analysis failed: %s", e) return 0 diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 7be3c83a9a..c5225a7a14 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except # Capture all exceptions so ``event`` is always set — otherwise a # crash would hang the waiter forever. self.exception = exc diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 92cbb7348a..f4fd205285 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -43,7 +43,7 @@ def save_compiled_config(config: ConfigType) -> None: try: rendered = yaml_util.dump(config, show_secrets=True) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -62,7 +62,7 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: try: config = yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7b94a26f54..703463bee9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2683,7 +2683,7 @@ def _decode_pc(config, addr): command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 38df282fb9..dd10a32fd6 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -472,7 +472,7 @@ def _decode_pc(config, addr): command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 4ba1ab5d4d..48b67e1ef9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -535,7 +535,7 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: check=True, ) return result.stdout.strip().splitlines()[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) return "" diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 97d6639c1f..f5203efe9c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1379,7 +1379,7 @@ class LoginHandler(BaseHandler): loop = asyncio.get_running_loop() try: req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("Error during Hass.io auth request: %s", err) self.set_status(500) self.render_login_page(error="Internal server error") diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index bead63ca21..5f59254aee 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -120,7 +120,7 @@ def run_extra_script( "__name__": "__pio_extra_script__", }, ) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e) return ExtraScriptResult() finally: diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 331c2f84b0..b2251d00d8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -783,7 +783,7 @@ def download_from_mirrors( f.seek(0) return url - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught _LOGGER.debug("Failed to download %s: %s", url, str(e)) last_exception = e diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index caab47dcc2..c49220a044 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -94,7 +94,7 @@ def patch_file_downloader() -> None: self._http_response.close() if hasattr(self, "_http_session"): self._http_session.close() - except Exception: + except Exception: # noqa: BLE001 pass # pylint: enable=protected-access,broad-except time.sleep(delay) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 04f5881465..3df12f3985 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -267,7 +267,7 @@ class StorageJSON: def load(path: Path) -> StorageJSON | None: try: return StorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None def apply_to_core(self) -> None: @@ -342,7 +342,7 @@ class EsphomeStorageJSON: return datetime.strptime( # noqa: DTZ007 self.last_update_check_str, "%Y-%m-%dT%H:%M:%S" ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @last_update_check.setter @@ -371,7 +371,7 @@ class EsphomeStorageJSON: def load(path: str) -> EsphomeStorageJSON | None: try: return EsphomeStorageJSON._load_impl(path) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except return None @staticmethod diff --git a/esphome/util.py b/esphome/util.py index 39ce7c0963..b597b4b42e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -271,7 +271,7 @@ def run_external_command( raise except SystemExit as err: return err.args[0] - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 @@ -318,7 +318,7 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.error("Running command failed: %s", err) _LOGGER.error("Please try running %s locally.", full_cmd) return 1 diff --git a/esphome/vscode.py b/esphome/vscode.py index 53bb339a8e..f404f02f00 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -134,13 +134,13 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except vs.add_yaml_error(str(err)) else: for err in res.errors: try: range_ = _get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except continue print(vs.dump()) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index a4f4f46097..e4b9abb976 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -342,7 +342,7 @@ async def async_discover_mdns_devices( ) try: aiozc = AsyncEsphomeZeroconf() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except # Zeroconf init can raise OSError, NonUniqueNameException, etc. # Any failure here just means we can't discover — log and move on. _LOGGER.warning("mDNS discovery failed to initialize: %s", err) diff --git a/pyproject.toml b/pyproject.toml index d2f30ea3d7..a292377835 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ exclude = ['generated'] [tool.ruff.lint] select = [ "B", # flake8-bugbear + "BLE", # flake8-blind-except "C4", # flake8-comprehensions "DTZ", # flake8-datetimez "E", # pycodestyle diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 1d86d5c71c..fc66605694 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -128,7 +128,7 @@ def uses_local_file_references(component_dir: Path) -> bool: try: content = common_yaml.read_text() - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False # Pattern to match $component_dir or ${component_dir} references @@ -164,7 +164,7 @@ def is_platform_component(component_dir: Path) -> bool: try: content = comp_init.read_text() return "IS_PLATFORM_COMPONENT = True" in content - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return False @@ -222,7 +222,7 @@ def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]: try: data = yaml_util.load_yaml(yaml_file) result["loaded"] = True - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught return result # Check for Extend/Remove objects diff --git a/script/build_helpers.py b/script/build_helpers.py index 52f7ee317e..eaf3a1f1a7 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -392,7 +392,7 @@ def compile_and_get_binary( if exit_code != 0: print(f"Error compiling {label} for {', '.join(components)}") return exit_code, None - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error compiling {label} for {', '.join(components)}: {e}") return EXIT_COMPILE_ERROR, None diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d91936952e..cf098f92c9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -312,7 +312,7 @@ def _is_clang_tidy_full_scan() -> bool: ) # Exit 0 means hash changed (full scan needed) return result.returncode == 0 - except Exception: + except Exception: # noqa: BLE001 # If hash check fails, run full scan to be safe return True diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index df7ad4a28c..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -437,7 +437,7 @@ def main() -> None: tests_dir=args.tests_dir, output_file=args.output, ) - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error merging configs: {e}", file=sys.stderr) import traceback diff --git a/script/stress_test_connect.py b/script/stress_test_connect.py index f91a7e8f99..e34cffb8e2 100644 --- a/script/stress_test_connect.py +++ b/script/stress_test_connect.py @@ -21,7 +21,7 @@ async def connect_disconnect(client_id: int, iteration: int) -> tuple[int, bool, await asyncio.wait_for(cli.connect(login=True), timeout=10) await cli.disconnect() return iteration, True, "" - except Exception as e: + except Exception as e: # noqa: BLE001 return ( iteration, False, diff --git a/script/test_component_grouping.py b/script/test_component_grouping.py index a2cee6e888..1e7dfc1792 100755 --- a/script/test_component_grouping.py +++ b/script/test_component_grouping.py @@ -63,7 +63,7 @@ def test_component_group( try: result = subprocess.run(cmd, check=False) return result.returncode == 0 - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"Error running test: {e}") return False diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index b31a19392c..0567164805 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -110,7 +110,7 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: receiver.on_message(msg) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 2187d13814..4ee3bba444 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -80,7 +80,7 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]] receiver.on_message(data) except BlockingIOError: await asyncio.sleep(0.01) - except Exception: + except Exception: # noqa: BLE001 break task = asyncio.create_task(receive_messages()) From 21e548f1d78a3ed225694bb9ef3d8df7feab71cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 09:20:50 -0500 Subject: [PATCH 167/282] [core] Sensitive redaction via yaml_util representer (#16690) --- esphome/__main__.py | 38 +++++- esphome/components/wifi/__init__.py | 6 +- esphome/config_validation.py | 10 +- esphome/yaml_util.py | 39 +++++- tests/unit_tests/test_config_validation.py | 37 ++++++ tests/unit_tests/test_main.py | 131 +++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 55 +++++++++ 7 files changed, 306 insertions(+), 10 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 03f12c75d7..000087063f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,17 +1412,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) - # add the console decoration so the front-end can hide the secrets if not args.show_secrets: - output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output - ) + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback for unmigrated schemas; removed in +# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips +# values that already render themselves: ``\033[8m`` (SensitiveStr wrap), +# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line +# block; first line is structural). The fragment must either start the +# field name or follow ``_`` so the warning names a real field; this avoids +# false positives like ``monkey:`` matching the ``key`` fragment. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: " + r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + + def _replace(m: re.Match[str]) -> str: + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + + output = _LEGACY_REDACTION_RE.sub(_replace, output) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4e7dcc82e5..b7719c80d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -271,7 +271,7 @@ EAP_AUTH_SCHEMA = cv.All( WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), - cv.Optional(CONF_SSID): cv.ssid, + 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, @@ -434,7 +434,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_NETWORKS): cv.All( cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) ), - cv.Optional(CONF_SSID): cv.ssid, + 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_EAP): EAP_AUTH_SCHEMA, @@ -850,7 +850,7 @@ async def final_step(): WiFiConfigureAction, cv.Schema( { - cv.Required(CONF_SSID): cv.templatable(cv.ssid), + cv.Required(CONF_SSID): cv.sensitive(cv.templatable(cv.ssid)), cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)), cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean), cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2f09fdc105..0ef6d212fe 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -101,7 +101,7 @@ from esphome.schema_extractors import ( ) from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema -from esphome.yaml_util import make_data_base +from esphome.yaml_util import SensitiveStr, make_data_base _LOGGER = logging.getLogger(__name__) @@ -514,7 +514,13 @@ class SensitiveValidator: self.inner = inner def __call__(self, value: typing.Any) -> typing.Any: - return self.inner(value) + validated = self.inner(value) + # Tag string results so yaml_util.dump can mask them. Non-string + # results pass through unchanged; already-tagged values are not + # re-wrapped to keep nested cv.sensitive applications idempotent. + if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + return SensitiveStr(validated) + return validated def __repr__(self) -> str: # Mirror the inner validator's repr so ``build_language_schema``'s diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 28f72ab831..bfe1fb0136 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -52,6 +52,16 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +class SensitiveStr(str): + """Marker subclass for validated strings that should be masked in + user-visible YAML output. ``cv.sensitive`` wraps validated values in this + type so ``dump()`` can render them with ANSI conceal codes without + needing a post-process regex. + """ + + __slots__ = () + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -808,11 +818,18 @@ def dump(dict_, show_secrets=False, sort_keys=False): if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + + # Per-call subclass so the redaction flag doesn't leak across calls. + # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML + # processing is single-threaded today, so this isolates only the flag.) + class _Dumper(ESPHomeDumper): + _redact_sensitive = not show_secrets + return yaml.dump( dict_, default_flow_style=False, allow_unicode=True, - Dumper=ESPHomeDumper, + Dumper=_Dumper, sort_keys=sort_keys, ) @@ -958,6 +975,10 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): + # Default for the base class; per-call subclass in ``dump()`` overrides. + # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. + _redact_sensitive: bool = False + def represent_mapping(self, tag, mapping, flow_style=None): value = [] node = yaml.MappingNode(tag, value, flow_style=flow_style) @@ -992,6 +1013,20 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + # Only the redact-and-not-a-secret branch is unique to sensitive + # values; otherwise let ``represent_stringify`` handle ``!secret`` + # precedence and the plain-str fallthrough. Conceal sequence is + # emitted as literal ``\033`` text (not actual ESC bytes) so the + # output matches the prior regex format and device-builder's + # ``\033[8m...\033[28m`` parser keeps working. + if self._redact_sensitive and not is_secret(value): + return self.represent_scalar( + tag="tag:yaml.org,2002:str", + value=f"\\033[8m{value}\\033[28m", + ) + return self.represent_stringify(value) + # pylint: disable=arguments-renamed def represent_bool(self, value): return self.represent_scalar( @@ -1063,6 +1098,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) +# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. +ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2c34cbfb07..74d9a5047a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -27,6 +27,7 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda +from esphome.yaml_util import SensitiveStr def test_check_not_templatable__invalid(): @@ -145,6 +146,42 @@ def test_sensitive__custom_inner_delegates_validation() -> None: validator(123) +def test_sensitive__wraps_string_result_in_sensitive_str() -> None: + validator = config_validation.sensitive() + result = validator("hunter2") + + assert isinstance(result, SensitiveStr) + assert isinstance(result, str) + assert result == "hunter2" + + +def test_sensitive__does_not_double_tag_already_sensitive() -> None: + # If the inner validator already returns a SensitiveStr (e.g., nested + # cv.sensitive wrappers), re-tagging is a no-op rather than a new + # SensitiveStr around the same value. + pre_tagged = SensitiveStr("hunter2") + + def inner(_value): + return pre_tagged + + validator = config_validation.sensitive(inner) + result = validator("anything") + + assert result is pre_tagged + + +def test_sensitive__non_string_result_passes_through() -> None: + # If an inner validator returns something other than a string (e.g., a + # Lambda template), the sensitive wrapper must not coerce it. + sentinel = object() + + def inner(_value): + return sentinel + + validator = config_validation.sensitive(inner) + assert validator("anything") is sentinel + + def test_sensitive__is_detectable_via_isinstance() -> None: validator = config_validation.sensitive() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f6b6d0b05f..26b550669f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -22,6 +22,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _redact_with_legacy_fallback, _resolve_network_devices, _validate_bootloader_binary, _validate_partition_table_binary, @@ -29,6 +30,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config, command_config_hash, command_rename, command_run, @@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]: yield mock_class +def test_redact_with_legacy_fallback__wraps_unmarked_field( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unmarked sensitive-shaped fields are redacted; a deprecation warning + is emitted naming the field.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("password: hunter2\n") + assert "password: \\033[8mhunter2\\033[28m" in out + assert any( + "password" in rec.message and "cv.sensitive" in rec.message + for rec in caplog.records + ) + + +def test_redact_with_legacy_fallback__skips_already_wrapped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Values already wrapped by the SensitiveStr representer don't trigger + the heuristic or the warning.""" + wrapped = "password: \\033[8mhunter2\\033[28m\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(wrapped) + assert out == wrapped + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__captures_full_field_name( + caplog: pytest.LogCaptureFixture, +) -> None: + """The warning names the actual field, not just the matched fragment.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback("encryption_key: abc\n") + assert any("encryption_key" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__deduplicates_warnings( + caplog: pytest.LogCaptureFixture, +) -> None: + """One warning per unique field name even if it appears many times.""" + text = "password: a\npassword: b\npassword: c\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + _redact_with_legacy_fallback(text) + password_warnings = [rec for rec in caplog.records if "'password'" in rec.message] + assert len(password_warnings) == 1 + + +def test_redact_with_legacy_fallback__skips_lambda_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!lambda`` first line is structural, body is unreachable by a + single-line regex anyway, and tagged fields shouldn't trigger a warning.""" + text = ' ssid: !lambda |-\n return "x";\n' + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__skips_secret_references( + caplog: pytest.LogCaptureFixture, +) -> None: + """``!secret name`` is the dumper's user-friendly representation; the + name isn't the secret, so wrapping it would clobber the round-trip.""" + text = " password: !secret wifi_password\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert out == text + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must end the field name; embedded matches like + ``key_value_pair`` are unrelated to a sensitive key and must not be + redacted (matching the prior regex's scope).""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("key_value_pair: abc\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( + caplog: pytest.LogCaptureFixture, +) -> None: + """Fragment must start the name or follow ``_``; ``monkey:`` shouldn't + fire a 'legacy heuristic' warning because there's no sensitive field + here — the user has nothing to migrate.""" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback("monkey: 1234\n") + assert "\\033[8m" not in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_command_config__invokes_legacy_fallback_when_redacting( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``command_config`` runs the legacy fallback on the dumped output when + ``--show-secrets`` is off. Cover the wiring (not just the helper). + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = False + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "\\033[8mhunter2\\033[28m" in output + + +def test_command_config__show_secrets_skips_redaction( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """With ``--show-secrets`` the helper isn't invoked and the value + renders raw. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + + result = command_config(args, {"wifi": {"password": "hunter2"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "hunter2" in output + assert "\\033[8m" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index d6fb5b81f2..6be090b869 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -15,6 +15,7 @@ from esphome.yaml_util import ( DiscoveredYamlFiles, ESPHomeDataBase, ESPLiteralValue, + SensitiveStr, discover_user_yaml_files, force_load_include_files, format_path, @@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None: assert main.resolve() not in core.CORE.frontmatter # Included file's frontmatter is captured assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello" + + +def test_sensitive_str__is_a_str_subclass() -> None: + value = SensitiveStr("hunter2") + assert isinstance(value, str) + assert value == "hunter2" + + +def test_dump__redacts_sensitive_str_by_default() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}) + assert "\\033[8mhunter2\\033[28m" in out + assert "hunter2" not in out.replace( + "\\033[8mhunter2\\033[28m", "" + ) # the raw value is only present inside the wrap + + +def test_dump__show_secrets_emits_sensitive_str_raw() -> None: + out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + assert "hunter2" in out + assert "\\033[8m" not in out + assert "\\033[28m" not in out + + +def test_dump__plain_str_is_not_redacted() -> None: + out = yaml_util.dump({"hostname": "myserver"}) + assert "myserver" in out + assert "\\033[8m" not in out + + +def test_dump__secret_reference_wins_over_redaction() -> None: + # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded + # via !secret), the dump should render it as !secret , not as a + # redacted scalar. SensitiveStr layered on top must not change that. + value = SensitiveStr("hunter2") + yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" + try: + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out + finally: + yaml_util._SECRET_VALUES.clear() + + +def test_dump__redaction_flag_does_not_leak_between_calls() -> None: + # Per-call _Dumper subclass means show_secrets in one call doesn't + # affect another. Run them in both orders to catch any leakage. + redacted = yaml_util.dump({"password": SensitiveStr("hunter2")}) + raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) + redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")}) + + assert "\\033[8m" in redacted + assert "\\033[8m" not in raw + assert "\\033[8m" in redacted_again From e64b6bc3982936b0cdedce5f9ea5052cde7467ad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 27 May 2026 11:00:51 -0400 Subject: [PATCH 168/282] [esp32] Stub arduino-esp32 with INTERFACE re-export to framework (#16695) --- esphome/components/esp32/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 703463bee9..ac0d2eaba2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2583,6 +2583,26 @@ def _write_idf_component_yml(): "override_path": str(stub_path), } + # On the PlatformIO toolchain, framework-arduinoespressif32 already + # ships arduino-esp32. Stub the managed component so anything that + # `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a + # CMake target that re-exports the framework's INTERFACE properties + # (INCLUDE_DIRS, public compile options like -DESP32, transitive + # REQUIRES) instead of triggering a duplicate download/rebuild. + if CORE.using_toolchain_platformio: + arduino_stub = stubs_dir / "arduino-esp32" + arduino_stub.mkdir(exist_ok=True) + write_file_if_changed( + arduino_stub / "CMakeLists.txt", + "idf_component_register()\n" + "target_link_libraries(${COMPONENT_LIB} " + f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n", + ) + dependencies[ARDUINO_ESP32_COMPONENT_NAME] = { + "version": "*", + "override_path": str(arduino_stub), + } + # Remove stubs for components that are now required by enabled libraries for component_name in required_idf_components: stub_path = stubs_dir / _idf_component_stub_name(component_name) From 911e330c0948231b8141474827f1ce22aaba1345 Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Wed, 27 May 2026 20:13:03 +0200 Subject: [PATCH 169/282] [core] Add Codeberg as a supported git url (#16501) --- esphome/git.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/git.py b/esphome/git.py index 744ce35ef6..c4a612753b 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -341,6 +341,7 @@ def clone_or_update( GIT_DOMAINS = { + "codeberg": "codeberg.org", "github": "github.com", "gitlab": "gitlab.com", } @@ -363,6 +364,8 @@ class GitFile: def raw_url(self) -> str: if self.ref is None: raise ValueError("URL has no ref") + if self.domain == "codeberg.org": + return f"https://codeberg.org/{self.owner}/{self.repo}/raw/commit/{self.ref}/{self.filename}" if self.domain == "github.com": return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.ref}/{self.filename}" if self.domain == "gitlab.com": From e87190edb49356f9cad167c9a788853f89e2f11f Mon Sep 17 00:00:00 2001 From: SoCuul <63339559+SoCuul@users.noreply.github.com> Date: Wed, 27 May 2026 11:20:00 -0700 Subject: [PATCH 170/282] [midea] fix casing of custom fan modes (#16419) --- esphome/components/midea/ac_adapter.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index ec9dc10297..2f4ef5c948 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -6,9 +6,9 @@ namespace esphome::midea::ac { const char *const Constants::TAG = "midea"; -const char *const Constants::FREEZE_PROTECTION = "freeze protection"; -const char *const Constants::SILENT = "silent"; -const char *const Constants::TURBO = "turbo"; +const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; +const char *const Constants::SILENT = "Silent"; +const char *const Constants::TURBO = "Turbo"; ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { From ac29fad120115230cb52298cc3f43d9a44bb2978 Mon Sep 17 00:00:00 2001 From: GuzTech Date: Wed, 27 May 2026 20:21:50 +0200 Subject: [PATCH 171/282] [growatt_solar] Replace hard coded register addresses with constexpr (#16581) --- .../growatt_solar/growatt_solar.cpp | 105 ++++++++++-------- .../components/growatt_solar/growatt_solar.h | 49 ++++++++ 2 files changed, 110 insertions(+), 44 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 41beb6e4e9..fc35271017 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector &data) { switch (this->protocol_version_) { case RTU: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, + RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, + RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, + RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, + RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } case RTU2: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, + RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, + RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, + RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1, + ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, + RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } } diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 7eba795601..27ae32cc46 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -16,6 +16,55 @@ enum GrowattProtocolVersion { RTU2, }; +// Register addresses for the RTU protocol. +constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 + +// Input register addresses for the RTU2 protocol as described +// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document. +constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 +constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 +constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 + class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; From 9a6157b469225923d68baa501af4e29fe32df41d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 27 May 2026 15:50:43 -0400 Subject: [PATCH 172/282] [tests] Sandbox PlatformIO paths in test_writer to fix xdist race (#16619) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/unit_tests/test_writer.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index fc49f03067..d6df559571 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -44,6 +44,42 @@ from esphome.writer import ( ) +@pytest.fixture(autouse=True) +def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: + """Sandbox PlatformIO path lookups so tests never touch ~/.platformio. + + `clean_all` and `clean_build` both query ProjectConfig for paths like + `cache_dir` and `core_dir` and `rmtree` anything that exists. By + default `core_dir` resolves to ~/.platformio, which is global state + shared across pytest-xdist workers — multiple workers can each pass + `is_dir()` and then race inside `shutil.rmtree`, producing + FileNotFoundError flakes (and trashing developers' local PIO state + when the suite is run outside CI). + + 14 of the 18 `clean_*` tests in this file invoke `clean_all` / + `clean_build` without installing their own ProjectConfig mock, so + making the fixture autouse is simpler than tagging each test + individually. + + Patch ProjectConfig.get_instance to point every PIO dir at a unique + tmp directory that doesn't actually exist on disk — `is_dir()` + returns False, so the rmtree loop is skipped entirely. Tests that + want to verify the PIO-cleanup branch (e.g. test_clean_all, + test_clean_all_partial_exists) install their own inner patch which + stacks on top of this one and wins for the duration of their block. + """ + pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + mock_cfg = MagicMock() + mock_cfg.get.side_effect = lambda section, option: ( + str(pio_root / option) if section == "platformio" else "" + ) + with patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ): + yield + + @pytest.fixture def mock_copy_src_tree(): """Mock copy_src_tree to avoid side effects during tests.""" From ec597bfc0349b48dd03d110659b9181e7617df59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 28 May 2026 14:54:42 +1200 Subject: [PATCH 173/282] [docs] Update esphome-docs references to esphome.io after repo rename (#16705) --- .claude/skills/pr-workflow/SKILL.md | 8 ++++---- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- .github/scripts/auto-label-pr/constants.js | 3 +++ AGENTS.md | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 4ec2551804..2c529dcd0f 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -29,7 +29,7 @@ Required fields: - **What does this implement/fix?**: Brief description of changes - **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.) - **Related issue**: Use `fixes ` syntax if applicable -- **Pull request in esphome-docs**: Link if docs are needed +- **Pull request in esphome.io**: Link if docs are needed - **Test Environment**: Check platforms you tested on - **Example config.yaml**: Include working example YAML - **Checklist**: Verify code is tested and tests added @@ -54,9 +54,9 @@ Required fields: - fixes https://github.com/esphome/esphome/issues/XXX -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs#XXX +- esphome/esphome.io#XXX ## Test Environment @@ -83,7 +83,7 @@ component_name: - [x] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). ``` ## 5. Push and Create PR diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19f52349a6..3b39d519c4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Report an issue with the ESPHome documentation - url: https://github.com/esphome/esphome-docs/issues/new/choose + url: https://github.com/esphome/esphome.io/issues/new/choose about: Report an issue with the ESPHome documentation. - name: Report an issue with the ESPHome web server url: https://github.com/esphome/esphome-webserver/issues/new/choose diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 72013e411e..08def88577 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,9 +16,9 @@ - fixes -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs# +- esphome/esphome.io# ## Test Environment @@ -43,4 +43,4 @@ - [ ] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index e02b450bf0..2938fd923c 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -35,6 +35,9 @@ module.exports = { ], DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/, + /esphome\/esphome\.io#\d+/, + // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ ] diff --git a/AGENTS.md b/AGENTS.md index 2139a2b796..4adc53cae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,7 +462,7 @@ This document provides essential context for AI models interacting with this pro 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** - * Documentation is hosted in the separate `esphome/esphome-docs` repository. + * Documentation is hosted in the separate `esphome/esphome.io` repository. * The contribution workflow is the same as for the codebase. * When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync. @@ -681,7 +681,7 @@ This document provides essential context for AI models interacting with this pro - [ ] Explored non-breaking alternatives - [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++) - [ ] Documented migration path in PR description with before/after examples - - [ ] Updated all internal usage and esphome-docs + - [ ] Updated all internal usage and esphome.io - [ ] Tested backward compatibility during deprecation period * **Deprecation Pattern (C++):** From 5732d7135f395b7a3b5de5cec4c8ffd2b48b08bf Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 28 May 2026 14:39:11 +0200 Subject: [PATCH 174/282] [network] move ipv6 enforcement to validation step (#16701) --- esphome/components/network/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 3bb14a05a7..b662293ab5 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -108,6 +108,13 @@ def has_high_performance_networking() -> bool: return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False) +def validate_ipv6(value: bool) -> bool: + if CORE.is_nrf52 and not value: + raise cv.Invalid("On nRF52, enable_ipv6 must be true") + + return value + + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(NetworkComponent), @@ -133,6 +140,7 @@ CONFIG_SCHEMA = cv.Schema( ), cv.boolean_false, ), + validate_ipv6, ), cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), @@ -209,13 +217,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) if CORE.is_nrf52: - enable_ipv6 = config.get(CONF_ENABLE_IPV6, True) - if not enable_ipv6: - _LOGGER.warning( - "IPv6 cannot be disabled on nRF52 because the Zephyr IPAddress implementation is IPv6-only. " - "Forcing CONFIG_NET_IPV6=y." - ) - config[CONF_ENABLE_IPV6] = True zephyr_add_prj_conf("NETWORKING", True) zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) From f41866a9b8ba9b8711e325f367733354bf2b5d4b Mon Sep 17 00:00:00 2001 From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Thu, 28 May 2026 15:11:48 +0200 Subject: [PATCH 175/282] [gpio][binary_sensor] Fix pin validation for external GPIO pins (#16528) --- esphome/components/gpio/binary_sensor/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 390b26ba1d..f14a920c24 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -74,8 +74,6 @@ def _final_validate(config): if not use_interrupt: return config - pin_num = config[CONF_PIN][CONF_NUMBER] - # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: @@ -87,6 +85,8 @@ def _final_validate(config): config[CONF_USE_INTERRUPT] = False return config + pin_num = config[CONF_PIN][CONF_NUMBER] + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( From 4b8e06b5bc454b0de2af07522a0c3a4e71625c49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:12:35 -0400 Subject: [PATCH 176/282] Bump tornado from 6.5.5 to 6.5.6 (#16704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 14dddbb1aa..17b618dde7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.5 +tornado==6.5.6 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 8945550c6c375d4097d4b0ae620ce2da30f6162c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 03:35:37 +0000 Subject: [PATCH 177/282] Bump ruff from 0.15.14 to 0.15.15 (#16712) Co-authored-by: J. Nick Koston 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 0470a948f5..a076128975 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.14 + rev: v0.15.15 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index aad1da0807..203cd2bbea 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.14 # also change in .pre-commit-config.yaml when updating +ruff==0.15.15 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From a85f8ad9359c041eedbb64ccd6ea3c1ca2f1e6df Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:28:08 -0400 Subject: [PATCH 178/282] [core] Use esp_rom_crc.h public API instead of legacy rom/crc.h (#16698) --- esphome/core/helpers.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1eb3345491..112dde7c45 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -15,7 +15,7 @@ #include #ifdef USE_ESP32 -#include "rom/crc.h" +#include "esp_rom_crc.h" #endif namespace esphome { @@ -47,7 +47,7 @@ static const uint16_t CRC16_8408_LE_LUT_H[] = {0x0000, 0x1081, 0x2102, 0x3183, 0 0x8408, 0x9489, 0xa50a, 0xb58b, 0xc60c, 0xd68d, 0xe70e, 0xf78f}; #endif -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 static const uint16_t CRC16_1021_BE_LUT_L[] = {0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef}; static const uint16_t CRC16_1021_BE_LUT_H[] = {0x0000, 0x1231, 0x2462, 0x3653, 0x48c4, 0x5af5, 0x6ca6, 0x7e97, @@ -86,7 +86,7 @@ uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc, uint8_t poly, bool m uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse_poly, bool refin, bool refout) { #ifdef USE_ESP32 if (reverse_poly == 0x8408) { - crc = crc16_le(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_le(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif @@ -124,23 +124,24 @@ uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t reverse } uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, bool refin, bool refout) { -#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32S2) +#ifdef USE_ESP32 if (poly == 0x1021) { - crc = crc16_be(refin ? crc : (crc ^ 0xffff), data, len); + crc = esp_rom_crc16_be(refin ? crc : (crc ^ 0xffff), data, len); return refout ? crc : (crc ^ 0xffff); } #endif if (refin) { crc ^= 0xffff; } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) +#ifndef USE_ESP32 if (poly == 0x1021) { while (len--) { uint8_t combo = (crc >> 8) ^ *data++; crc = (crc << 8) ^ CRC16_1021_BE_LUT_L[combo & 0x0F] ^ CRC16_1021_BE_LUT_H[combo >> 4]; } - } else { + } else #endif + { while (len--) { crc ^= (((uint16_t) *data++) << 8); for (uint8_t i = 0; i < 8; i++) { @@ -151,9 +152,7 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, } } } -#if !defined(USE_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) } -#endif return refout ? (crc ^ 0xffff) : crc; } From 10abb0647c5c4204a1d4c3270b8f78284b50dbb5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:30:52 -0400 Subject: [PATCH 179/282] [esp32] Add ESP32-S31, ESP32-H4 and ESP32-H21 variant scaffolding (#16700) --- esphome/components/esp32/__init__.py | 32 ++++++++++++++++-- esphome/components/esp32/boards.py | 4 --- esphome/components/esp32/const.py | 9 +++++ esphome/components/esp32/gpio.py | 18 ++++++++++ esphome/components/esp32/gpio_esp32_h21.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_h4.py | 34 +++++++++++++++++++ esphome/components/esp32/gpio_esp32_s31.py | 38 ++++++++++++++++++++++ esphome/components/logger/__init__.py | 9 +++++ esphome/core/defines.h | 5 ++- tests/component_tests/esp32/test_esp32.py | 16 ++++++++- 10 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 esphome/components/esp32/gpio_esp32_h21.py create mode 100644 esphome/components/esp32/gpio_esp32_h4.py create mode 100644 esphome/components/esp32/gpio_esp32_s31.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac0d2eaba2..4e3ffdc1e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -78,9 +78,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, VARIANT_FRIENDLY, VARIANTS, ) @@ -403,9 +406,12 @@ CPU_FREQUENCIES = { VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), + VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96), + VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S31: get_cpu_frequencies(240, 320), } # Make sure not missed here if a new variant added. @@ -907,11 +913,16 @@ def _validate_toolchain(value) -> Toolchain: return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) -def _check_versions(config): +def _resolve_toolchain(value: ConfigType) -> ConfigType: # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + # Runs before _detect_variant so downstream validators can rely on + # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return value + +def _check_versions(config: ConfigType) -> ConfigType: if CORE.using_toolchain_esp_idf: return _check_esp_idf_versions(config) return _check_pio_versions(config) @@ -933,7 +944,21 @@ def _detect_variant(value): variant = value.get(CONF_VARIANT) if variant and board is None: # If variant is set, we can derive the board from it - # variant has already been validated against the known set + # variant has already been validated against the known set. + # PlatformIO needs a real board name to find its board file; the + # ESP-IDF toolchain only uses CONF_BOARD as the informational + # ESPHOME_BOARD string, so synthesize one from the friendly variant + # name rather than carrying a PIO board name through the IDF build. + if CORE.using_toolchain_esp_idf: + value = value.copy() + value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() + return value + if variant not in STANDARD_BOARDS: + raise cv.Invalid( + f"No default board is known for {variant}. " + f"Please specify the `board:` option explicitly.", + path=[CONF_VARIANT], + ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] if variant == VARIANT_ESP32P4: @@ -1606,6 +1631,7 @@ CONFIG_SCHEMA = cv.All( ), } ), + _resolve_toolchain, _detect_variant, _set_default_framework, _check_versions, diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2c73fe7d08..6062631d98 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -9,7 +9,6 @@ from .const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANTS, ) STANDARD_BOARDS = { @@ -25,9 +24,6 @@ STANDARD_BOARDS = { VARIANT_ESP32S3: "esp32-s3-devkitc-1", } -# Make sure not missed here if a new variant added. -assert all(v in STANDARD_BOARDS for v in VARIANTS) - ESP32_BASE_PINS = { "TX": 1, "RX": 3, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index d0d00723fc..322054ea91 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -24,9 +24,12 @@ VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" +VARIANT_ESP32H4 = "ESP32H4" +VARIANT_ESP32H21 = "ESP32H21" VARIANT_ESP32P4 = "ESP32P4" VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" +VARIANT_ESP32S31 = "ESP32S31" VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C2, @@ -35,9 +38,12 @@ VARIANTS = [ VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] VARIANT_FRIENDLY = { @@ -48,9 +54,12 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", + VARIANT_ESP32H4: "ESP32-H4", + VARIANT_ESP32H21: "ESP32-H21", VARIANT_ESP32P4: "ESP32-P4", VARIANT_ESP32S2: "ESP32-S2", VARIANT_ESP32S3: "ESP32-S3", + VARIANT_ESP32S31: "ESP32-S31", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 36dd44155a..2ff39cab69 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -31,9 +31,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, esp32_ns, ) from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports @@ -43,9 +46,12 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports +from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports +from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -120,6 +126,14 @@ _esp32_validations = { pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, ), + VARIANT_ESP32H4: ESP32ValidationFunctions( + pin_validation=esp32_h4_validate_gpio_pin, + usage_validation=esp32_h4_validate_supports, + ), + VARIANT_ESP32H21: ESP32ValidationFunctions( + pin_validation=esp32_h21_validate_gpio_pin, + usage_validation=esp32_h21_validate_supports, + ), VARIANT_ESP32P4: ESP32ValidationFunctions( pin_validation=esp32_p4_validate_gpio_pin, usage_validation=esp32_p4_validate_supports, @@ -132,6 +146,10 @@ _esp32_validations = { pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, ), + VARIANT_ESP32S31: ESP32ValidationFunctions( + pin_validation=esp32_s31_validate_gpio_pin, + usage_validation=esp32_s31_validate_supports, + ), } diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py new file mode 100644 index 0000000000..5ab1b7c074 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. +_ESP32H21_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h21_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 25: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + return value + + +def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 25: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py new file mode 100644 index 0000000000..86a4d55858 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. +_ESP32H4_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h4_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 39: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + return value + + +def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 39: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py new file mode 100644 index 0000000000..6a19e3fee4 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -0,0 +1,38 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# Per the ESP32-S31 datasheet (page 96): +# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf +_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} +_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_s31_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 61: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)") + if value in _ESP32S31_SPI_FLASH_PINS: + raise cv.Invalid( + f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." + ) + return value + + +def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 61: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 5f160352cc..e4921ae196 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -11,9 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, require_usb_serial_jtag_secondary, @@ -113,9 +116,12 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32S2: [UART0, UART1, USB_CDC], VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -270,9 +276,12 @@ CONFIG_SCHEMA = cv.All( esp32_c6=USB_SERIAL_JTAG, esp32_c61=USB_SERIAL_JTAG, esp32_h2=USB_SERIAL_JTAG, + esp32_h4=USB_SERIAL_JTAG, + esp32_h21=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, + esp32_s31=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f536467e2f..765c1aa3b2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -354,9 +354,12 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC +#elif defined(USE_ESP32_VARIANT_ESP32H21) +#define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index f0f96e9adc..e0fcbab0ee 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -45,11 +45,20 @@ def test_esp32_config( config = CONFIG_SCHEMA(config) assert config["variant"] == VARIANT_ESP32 - # Check that defining a variant sets the board name correctly + # Check that defining a variant sets the board name correctly. + # Run under the ESP-IDF toolchain so variants without an entry in + # STANDARD_BOARDS (S31, H4, H21) still derive a board name from + # VARIANT_FRIENDLY rather than failing with cv.Invalid. CORE.toolchain + # gets pinned by the first CONFIG_SCHEMA() call above (via + # _resolve_toolchain) and that pinned value wins over the dict's + # CONF_TOOLCHAIN, so clear it between iterations to mirror a fresh + # config run. for variant in VARIANTS: + CORE.toolchain = None config = CONFIG_SCHEMA( { "variant": variant, + "toolchain": Toolchain.ESP_IDF.value, } ) assert VARIANT_FRIENDLY[variant].lower() in config["board"] @@ -73,6 +82,11 @@ def test_esp32_config( r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + {"variant": "esp32s31"}, + r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", + id="variant_without_default_board_requires_explicit_board_under_platformio", + ), pytest.param( { "variant": "esp32s2", From dd961156d098ec3656a67021bd68e5c0a77633e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 00:54:14 -0400 Subject: [PATCH 180/282] [ledc] Adapt to LEDC LL API changes in ESP-IDF 6.1 (#16697) --- esphome/components/ledc/ledc_output.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 5b7b6c7ee6..bfb629143d 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -53,7 +53,11 @@ static_assert( "re-evaluate for this target"); static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + auto *hw = LEDC_LL_GET_HW(0); +#else auto *hw = LEDC_LL_GET_HW(); +#endif return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; } #endif @@ -161,7 +165,9 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) PERIPH_RCC_ATOMIC() { ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(false); From 091a05ccde035ed9812aaedabf8b171f9d6aacb7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 29 May 2026 01:16:55 -0400 Subject: [PATCH 181/282] [esp32_camera] Enable PicolibC Newlib compatibility on IDF 6.0+ (#16703) --- esphome/components/camera_encoder/__init__.py | 7 ++- esphome/components/esp32/__init__.py | 43 ++++++++++++++----- esphome/components/esp32_camera/__init__.py | 8 +++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..7d4cdc881e 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,5 +1,8 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import ( + add_idf_component, + require_libc_picolibc_newlib_compat, +) import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -51,6 +54,8 @@ async def to_code(config: ConfigType) -> None: cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4e3ffdc1e4..beb41b30f4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1245,6 +1245,7 @@ KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" +KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" def require_vfs_select() -> None: @@ -1353,6 +1354,15 @@ def require_adc_oneshot_iram() -> None: CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True +def require_libc_picolibc_newlib_compat() -> None: + """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. + + Call this from components that link against precompiled Newlib binaries + referencing types/symbols the shim provides (e.g. esp32-camera). + """ + CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1758,6 +1768,26 @@ async def _write_arduino_libraries_sdkconfig() -> None: add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs) +@coroutine_with_priority(CoroPriority.FINAL) +async def _set_libc_picolibc_newlib_compat() -> None: + """Apply the PicolibC Newlib compatibility shim option on IDF 6.0+. + + IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default. + Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call + (default priority) is seen before the option is written. A user-supplied + sdkconfig_options value takes precedence. + """ + if idf_version() < cv.Version(6, 0, 0): + return + option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY" + if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: + return + add_idf_sdkconfig_option( + option, + CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False), + ) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2291,17 +2321,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # Disable PicolibC Newlib compatibility shim on IDF 6.0+ - # IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local - # stdin/stdout/stderr and getreent() for code compiled against Newlib. - # ESPHome doesn't link against Newlib-built libraries that use stdio. - # If a component needs it (e.g. precompiled Newlib binaries), re-enable via: - # esp32: - # framework: - # sdkconfig_options: - # CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y" - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False) + # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call + CORE.add_job(_set_libc_picolibc_newlib_compat) # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 9883a0a43e..763a1f3405 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,7 +3,11 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + require_libc_picolibc_newlib_compat, +) from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -402,6 +406,8 @@ async def to_code(config): add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) + # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream + require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) From 07a57d7557bc451f4e66dc0f81818bd98112b35d Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 182/282] [sx126x] fix a typo in image calibration on 863 - 870 Mhz frequency (#16731) --- esphome/components/sx126x/sx126x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 83afeac50a..aed0105e1f 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -394,7 +394,7 @@ void SX126x::run_image_cal() { buf[1] = 0xE9; } else if (this->frequency_ > 850000000) { buf[0] = 0xD7; - buf[1] = 0xD8; + buf[1] = 0xDB; } else if (this->frequency_ > 770000000) { buf[0] = 0xC1; buf[1] = 0xC5; From f0202155b318f42b8166d5d2046d0ea036a14616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 183/282] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 6125c4ecc9..8214fcf80c 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -722,6 +722,7 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: + CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3df12f3985..ba576fcfd7 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b5b35b5172..ff150f2540 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is 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]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index b3f8a05605..105d78505f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None From 95397948b9a2a55514c4de84c131f082cb825213 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:09:27 -0500 Subject: [PATCH 184/282] Bump CodSpeedHQ/action from 4.15.1 to 4.17.0 (#16730) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53516db913..63efff1b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 + uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 with: run: | . venv/bin/activate From bf621240324db870dbce2909acdfafb0cffa949a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 30 May 2026 07:43:21 -0400 Subject: [PATCH 185/282] [esp32] Refine ESP-IDF framework version suffix handling (#16726) --- esphome/components/esp32/__init__.py | 35 ++++++++++++++++++++-------- esphome/espidf/framework.py | 9 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index beb41b30f4..d2dc979966 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -470,21 +470,20 @@ def set_core_data(config): framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver - # Store the underlying IDF version for framework-agnostic checks + # Store the underlying IDF version for framework-agnostic checks. if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver - elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - if CORE.using_toolchain_esp_idf: - # Official ESP-IDF frameworks don't use extra - idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch) - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver - else: + idf_ver = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None: raise cv.Invalid( f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " "Please update ARDUINO_IDF_VERSION_LOOKUP.", path=[CONF_FRAMEWORK, CONF_VERSION], ) + # The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does. + if CORE.using_toolchain_esp_idf: + idf_ver = _strip_pioarduino_revision(idf_ver) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant @@ -721,6 +720,9 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 4, 0, 0, "alpha1" + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -741,6 +743,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # 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, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -835,6 +838,16 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version: return version +def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version: + """Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1"). + + Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept. + """ + if ver.extra.isdigit(): + return cv.Version(ver.major, ver.minor, ver.patch) + return ver + + def _check_pio_versions(config: ConfigType) -> ConfigType: config = config.copy() value = config[CONF_FRAMEWORK] @@ -903,8 +916,10 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: "If there are connectivity or build issues please remove the manual source." ) - # Official ESP-IDF frameworks don't use the 'extra' semver component. - value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) + # esp-idf framework only: drop pioarduino's packaging revision (config + download). + # Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key). + if value[CONF_TYPE] == FRAMEWORK_ESP_IDF: + value[CONF_VERSION] = str(_strip_pioarduino_revision(version)) return config diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index b2251d00d8..6ef73a2199 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -74,7 +74,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", - "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz", + "https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}{EXTRA}/esp-idf-v{MAJOR}.{MINOR}{EXTRA}.tar.xz", ] ) @@ -979,8 +979,9 @@ def _check_esphome_idf_framework_install( env: Optional dictionary of environment variables to set source_url: Optional override URL for the framework tarball. Supports the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / - ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When - set, it replaces the default mirror list — no implicit fallback, + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS + (``{EXTRA}`` includes its leading ``-``, e.g. ``-rc1``, or is empty). + When set, it replaces the default mirror list — no implicit fallback, so a misspelled URL fails loudly. Returns: @@ -1035,7 +1036,7 @@ def _check_esphome_idf_framework_install( substitutions["MAJOR"] = str(ver.major) substitutions["MINOR"] = str(ver.minor) substitutions["PATCH"] = str(ver.patch) - substitutions["EXTRA"] = ver.extra + substitutions["EXTRA"] = f"-{ver.extra}" if ver.extra else "" except ValueError: pass From 7865dc33bc8bb3420dab2d2115f6cd20ac5fe70d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 10:50:17 -0400 Subject: [PATCH 186/282] [ethernet] Bump espressif/dm9051 to 1.1.0 (#16735) --- esphome/components/ethernet/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3f88f8ef9a..22f5eb33e1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -163,7 +163,7 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), - "DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5af25fc351..9476b38b72 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -78,7 +78,7 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/dm9051: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: From 48844a68badaed2568f4f0581de0d559331d16a4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH 187/282] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ef7cbf5ac4..84f2f8101a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index d6df559571..6f137fb351 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -111,6 +111,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -142,6 +143,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -479,6 +494,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -499,11 +519,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -528,6 +551,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -537,6 +561,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text From 6116d10ab1f6f5168692d3fa6fc1de3152bfcb45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 17:44:12 -0400 Subject: [PATCH 188/282] [espidf] Derive idedata from the native ESP-IDF compile_commands.json (#16742) --- esphome/__main__.py | 1 + esphome/espidf/idedata.py | 178 ++++++++++++++++++++ esphome/espidf/toolchain.py | 28 ++++ tests/unit_tests/test_espidf_idedata.py | 196 ++++++++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 92 ++++++++++ 5 files changed, 495 insertions(+) create mode 100644 esphome/espidf/idedata.py create mode 100644 tests/unit_tests/test_espidf_idedata.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 000087063f..cc179ebf98 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -760,6 +760,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() + toolchain.get_idedata() else: from esphome.platformio import toolchain diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py new file mode 100644 index 0000000000..6fce8a55d9 --- /dev/null +++ b/esphome/espidf/idedata.py @@ -0,0 +1,178 @@ +"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``. + +PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF +toolchain has no such command, but its CMake build emits +``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module +turns that file into the same fields consumers (IDE integration, clang-tidy) +expect: + + {cxx_path, cxx_flags, defines, includes: {build, toolchain}} +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +import shlex +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# C++ translation-unit suffixes used to identify ESPHome source files. +_CXX_SUFFIXES = (".cpp", ".cc") +# Suffixes of input/output files that appear bare on the command line (and so +# must not be mistaken for compiler flags). +_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") +# Path marker identifying an ESPHome source translation unit. +_ESPHOME_SRC_MARKER = "/src/esphome/" + + +def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: + """Inline any ``@response-file`` arguments (paths relative to ``directory``). + + GCC response files embed flags that must be expanded so GCC-only flags + inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as + ``@file`` clang would read them and choke. + """ + out: list[str] = [] + for tok in tokens: + if tok.startswith("@"): + rf = Path(tok[1:]) + if not rf.is_absolute(): + rf = directory / rf + try: + out.extend( + _expand_response_files( + shlex.split(rf.read_text(encoding="utf-8")), directory + ) + ) + continue + except OSError as err: + # Keep the literal token if the file can't be read, but log it + # so the (otherwise opaque) downstream clang failure is traceable. + _LOGGER.warning("Could not read response file %s: %s", rf, err) + out.append(tok) + return out + + +def _pick_entry(entries: list[dict]) -> dict: + """Pick a representative ESPHome C++ translation unit. + + All ESPHome sources share the same component flags/defines, so any one of + them yields the cxx_path / cxx_flags / defines we need. + """ + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + return entry + for entry in entries: + if entry["file"].endswith(_CXX_SUFFIXES): + return entry + raise ValueError("no C++ translation unit found in compile_commands.json") + + +def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: + """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" + directory = Path(entry["directory"]) + tokens = _expand_response_files(shlex.split(entry["command"]), directory) + + def _include(raw: str) -> str: + # Include paths in compile_commands are interpreted relative to the + # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them + # so the cached idedata is usable regardless of the consumer's cwd. + raw = raw.strip() + if raw and not Path(raw).is_absolute(): + raw = os.path.normpath(directory / raw) + return raw + + cxx_path = tokens[0] + defines: list[str] = [] + includes: list[str] = [] + cxx_flags: list[str] = [] + + it = iter(tokens[1:]) + for tok in it: + if tok in ("-c", "-o"): + next(it, None) # drop the flag and its argument (input/output) + elif tok.startswith("-D"): + # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single + # quoted arg with a space after -D) that some flags arrive as. + defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip()) + elif tok.startswith("-I"): + includes.append(_include(tok[2:] if len(tok) > 2 else next(it, ""))) + elif tok == "-isystem": + includes.append(_include(next(it, ""))) + elif tok.startswith("-isystem"): + includes.append(_include(tok[len("-isystem") :])) + elif tok in ("-MT", "-MF", "-MQ"): + next(it, None) # dependency-file flag + its argument + elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")): + pass # dependency-generation flags, no argument + elif tok.endswith(_INPUT_FILE_SUFFIXES): + pass # input/output files + else: + cxx_flags.append(tok) + return cxx_path, defines, includes, cxx_flags + + +def _get_toolchain_includes(cxx_path: str) -> list[str]: + """Query the compiler for its builtin ``#include <...>`` search dirs.""" + result = subprocess.run( + [cxx_path, "-E", "-x", "c++", "-", "-v"], + input="", + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + close_fds=False, + ) + includes: list[str] = [] + capture = False + for line in result.stderr.splitlines(): + if "#include <...> search starts here:" in line: + capture = True + continue + if "End of search list." in line: + break + if capture: + includes.append(line.strip()) + if result.returncode != 0 or not includes: + raise RuntimeError( + f"Could not query builtin include dirs from {cxx_path} " + f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}" + ) + return includes + + +def idedata_from_build(compile_commands: Path) -> dict: + """Parse compile_commands.json into the idedata fields consumers expect. + + A single ESP-IDF compile entry only carries its own component's REQUIRES + include set, but consumers (clang-tidy) analyze ESPHome headers that + transitively pull in other components. So take cxx_path / cxx_flags / + defines from a representative ESPHome TU, but union the include dirs across + all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata + provides). + """ + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + + build_includes: dict[str, None] = {} + for entry in entries: + f = entry["file"] + if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + continue + for inc in _parse_entry(entry)[2]: + build_includes.setdefault(inc, None) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": list(build_includes), + "toolchain": _get_toolchain_includes(cxx_path), + }, + } diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 752f582e74..2fef3faf8d 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -443,6 +443,34 @@ def get_addr2line_path() -> Path: return _get_cmake_tool_path("CMAKE_ADDR2LINE") +def get_idedata() -> dict | None: + """Derive idedata from the build's compile_commands.json. + + The native ESP-IDF toolchain has no ``pio run -t idedata`` equivalent, but + its CMake build emits ``build/compile_commands.json``. Parse that into the + idedata fields IDE integrations and clang-tidy expect, cached alongside the + PlatformIO idedata path. Returns None if the compile DB doesn't exist yet. + """ + from esphome.espidf.idedata import idedata_from_build + + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + if not compile_commands.is_file(): + _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) + return None + + cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: + try: + return json.loads(cache.read_text(encoding="utf-8")) + except ValueError: + pass + + data = idedata_from_build(compile_commands) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return data + + def create_factory_bin() -> bool: """Create factory.bin by merging bootloader, partition table, and app.""" build_dir = CORE.relative_build_path("build") diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py new file mode 100644 index 0000000000..849ef274ed --- /dev/null +++ b/tests/unit_tests/test_espidf_idedata.py @@ -0,0 +1,196 @@ +"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata).""" + +# pylint: disable=protected-access + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.espidf import idedata + +# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so +# tests exercise the same is-absolute / normalize behavior as a real compile DB +# (a drive-qualified path on Windows, a leading slash elsewhere). +ABS = "C:/" if os.name == "nt" else "/" + + +def _entry(directory: str, file: str, command: str) -> dict: + return {"directory": directory, "file": file, "command": command} + + +def test_parse_entry_extracts_fields() -> None: + """cxx_path, defines, includes and remaining flags are split apart.""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 " + f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o", + ) + + cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry) + + assert cxx_path == "/tools/xtensa-esp32-elf-g++" + assert "USE_ESP32" in defines + assert "ESPHOME_LOG_LEVEL=5" in defines + assert f"{ABS}inc/a" in includes + assert f"{ABS}sys/b" in includes + assert "-std=gnu++20" in cxx_flags + # input/output files and their flags are not treated as flags + assert "-c" not in cxx_flags + assert "-o" not in cxx_flags + assert "app.cpp" not in cxx_flags + assert "app.cpp.o" not in cxx_flags + + +def test_parse_entry_space_separated_args() -> None: + """``-D X`` / ``-I path`` (separate arg) and ``-isystem`` (joined).""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/x.cpp", + f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp", + ) + + _, defines, includes, _ = idedata._parse_entry(entry) + + assert "FOO=1" in defines + assert f"{ABS}inc/sep" in includes + assert f"{ABS}sys/joined" in includes + + +def test_parse_entry_resolves_relative_includes() -> None: + """Relative includes are resolved against the entry's ``directory``.""" + directory = f"{ABS}build/proj" + entry = _entry( + directory, + f"{directory}/src/esphome/x.cpp", + "g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp", + ) + + _, _, includes, _ = idedata._parse_entry(entry) + + def resolved(rel: str) -> str: + return os.path.normpath(Path(directory) / rel) + + assert resolved("config") in includes + assert resolved("../shared") in includes # ../ normalized away + assert resolved("rel/sys") in includes + # nothing is left relative + assert all(Path(inc).is_absolute() for inc in includes) + + +def test_parse_entry_skips_dependency_flags() -> None: + """Dependency-generation flags (and their args) are dropped.""" + entry = _entry( + "/build", + "/build/src/esphome/x.cpp", + "g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o", + ) + + _, _, _, cxx_flags = idedata._parse_entry(entry) + + for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"): + assert tok not in cxx_flags + + +def test_expand_response_files(tmp_path: Path) -> None: + """``@file`` arguments are inlined relative to the directory.""" + rsp = tmp_path / "flags.rsp" + rsp.write_text("-DFROM_RSP -I/rsp/inc") + + tokens = idedata._expand_response_files( + ["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path + ) + + assert "-DFROM_RSP" in tokens + assert "-I/rsp/inc" in tokens + assert not any(t.startswith("@") for t in tokens) + + +def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None: + """An unreadable ``@file`` token is kept verbatim rather than dropped.""" + tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path) + assert "@nope.rsp" in tokens + + +def test_pick_entry_prefers_esphome_tu() -> None: + """A ``/src/esphome/`` C++ TU is picked over other compile entries.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("app.cpp") + + +def test_idedata_from_build(tmp_path: Path) -> None: + """Full transform: representative entry + include union + toolchain dirs.""" + compile_commands = tmp_path / "compile_commands.json" + entries = [ + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/core/app.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o", + ), + _entry( + f"{ABS}b", + f"{ABS}b/src/esphome/sensor/s.cpp", + f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o", + ), + # non-esphome TU: its includes must not leak into the union + _entry( + f"{ABS}b", + f"{ABS}b/managed_components/x/x.c", + f"gcc -I{ABS}inc/managed -c x.c", + ), + ] + compile_commands.write_text(json.dumps(entries)) + + fake_proc = MagicMock( + returncode=0, + stderr=( + "ignored\n" + "#include <...> search starts here:\n" + " /tc/inc/c++\n" + " /tc/inc\n" + "End of search list.\n" + "more ignored\n" + ), + ) + with patch.object(idedata.subprocess, "run", return_value=fake_proc): + data = idedata.idedata_from_build(compile_commands) + + assert data["cxx_path"] == "g++" + assert "USE_ESP32" in data["defines"] + assert "-std=gnu++20" in data["cxx_flags"] + # include dirs unioned across all esphome TUs + assert f"{ABS}inc/core" in data["includes"]["build"] + assert f"{ABS}inc/sensor" in data["includes"]["build"] + # the non-esphome TU is excluded from the union + assert f"{ABS}inc/managed" not in data["includes"]["build"] + # toolchain search dirs parsed from the compiler's -v output + assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"] + + +def test_get_toolchain_includes_raises_on_probe_failure() -> None: + """A failed compiler probe is a hard error, not a silent empty list.""" + fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found") + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/bad/compiler") + + +def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: + """Markers present but no dirs (anomalous output) also raises.""" + fake_proc = MagicMock( + returncode=0, + stderr="#include <...> search starts here:\nEnd of search list.\n", + ) + with ( + patch.object(idedata.subprocess, "run", return_value=fake_proc), + pytest.raises(RuntimeError, match="builtin include dirs"), + ): + idedata._get_toolchain_includes("/some/compiler") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index adc8bfce63..d00d8662f5 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -2,6 +2,9 @@ # pylint: disable=protected-access +import json +import os +from pathlib import Path from unittest.mock import patch from esphome.const import CONF_FRAMEWORK, CONF_SOURCE @@ -56,3 +59,92 @@ def test_get_esphome_esp_idf_paths_no_override(): ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") mock_install.assert_called_once_with("5.5.4", source_url=None) + + +def _setup_build(setup_core: Path) -> tuple[Path, Path]: + """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" + CORE.name = "test" + CORE.build_path = setup_core / "build" / "test" + compile_commands = CORE.relative_build_path("build", "compile_commands.json") + cache = CORE.relative_internal_path("idedata", "test.json") + return compile_commands, cache + + +def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None: + """No compile DB yet -> None (rather than an error).""" + _setup_build(setup_core) + assert toolchain.get_idedata() is None + + +def test_get_idedata_generates_and_caches(setup_core: Path) -> None: + """Generates from the compile DB and writes the cache.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "g++"} + assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + + +def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: + """A cache at least as new as the compile DB is reused without regenerating.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_not_called() + assert result == {"cxx_path": "cached"} + + +def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: + """A compile DB newer than the cache forces regeneration.""" + compile_commands, cache = _setup_build(setup_core) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "stale"}') + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache_mtime = cache.stat().st_mtime + os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "fresh"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "fresh"} + + +def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: + """An unparseable (but newer) cache falls back to regeneration.""" + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("{not json") + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "regen"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result == {"cxx_path": "regen"} From 805aa252d537490b4f73121acbd11646b4cfc11f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:30:05 +1000 Subject: [PATCH 189/282] [const] Move CONF_SHA256 to common code (#16751) --- esphome/components/const/__init__.py | 1 + .../esp32_hosted/update/__init__.py | 2 +- esphome/components/shelly_dimmer/light.py | 2 +- tests/components/const/common.yaml | 37 ------------------- tests/components/const/test.esp32-s3-idf.yaml | 4 -- 5 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 tests/components/const/common.yaml delete mode 100644 tests/components/const/test.esp32-s3-idf.yaml diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f418b48ea..3f7777883e 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SHA256 = "sha256" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 202df21ab5..8e85cce75a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE from esphome.core import CORE, ID, HexInt @@ -11,7 +12,6 @@ CODEOWNERS = ["@swoboda1337"] AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] -CONF_SHA256 = "sha256" CONF_HTTP_REQUEST_ID = "http_request_id" TYPE_EMBEDDED = "embedded" diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 97538e13c9..ddf7fa161b 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -7,6 +7,7 @@ import requests from esphome import pins import esphome.codegen as cg from esphome.components import light, sensor, uart +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, @@ -39,7 +40,6 @@ ShellyDimmer = shelly_dimmer_ns.class_( ) CONF_FIRMWARE = "firmware" -CONF_SHA256 = "sha256" CONF_UPDATE = "update" CONF_LEADING_EDGE = "leading_edge" diff --git a/tests/components/const/common.yaml b/tests/components/const/common.yaml deleted file mode 100644 index 109db65b63..0000000000 --- a/tests/components/const/common.yaml +++ /dev/null @@ -1,37 +0,0 @@ -display: - - platform: qspi_dbi - model: RM690B0 - data_rate: 80MHz - spi_mode: mode0 - dimensions: - width: 450 - height: 600 - offset_width: 16 - color_order: rgb - invert_colors: false - brightness: 255 - cs_pin: 11 - reset_pin: 13 - enable_pin: 9 - - - platform: qspi_dbi - model: CUSTOM - id: main_lcd - draw_from_origin: true - dimensions: - height: 240 - width: 536 - transform: - mirror_x: true - swap_xy: true - color_order: rgb - brightness: 255 - cs_pin: 6 - reset_pin: 17 - enable_pin: 38 - init_sequence: - - [0x3A, 0x66] - - [0x11] - - delay 120ms - - [0x29] - - delay 20ms diff --git a/tests/components/const/test.esp32-s3-idf.yaml b/tests/components/const/test.esp32-s3-idf.yaml deleted file mode 100644 index c335dee1f3..0000000000 --- a/tests/components/const/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - qspi: !include ../../test_build_components/common/qspi/esp32-s3-idf.yaml - -<<: !include common.yaml From 4e4868246818a1e2bbe13c11ff82dd64f07ad747 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 14:18:29 -0500 Subject: [PATCH 190/282] [wifi] Defer esp_wifi_init() to lazy-init so enable_on_boot: false actually saves RAM (#16606) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/components/wifi/wifi_component.cpp | 8 +++++++ esphome/components/wifi/wifi_component.h | 12 ++++++++++ .../wifi/wifi_component_esp_idf.cpp | 22 ++++++++++++++++--- .../wifi/test-lifecycle.esp32-idf.yaml | 15 +++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fdbd70bc61..07cb2ac243 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -632,6 +632,9 @@ void WiFiComponent::setup() { #endif if (this->enable_on_boot_) { +#ifdef USE_ESP32 + this->wifi_lazy_init_(); +#endif this->start(); } else { this->state_ = WIFI_COMPONENT_STATE_DISABLED; @@ -1275,6 +1278,11 @@ void WiFiComponent::enable() { ESP_LOGD(TAG, "Enabling"); this->state_ = WIFI_COMPONENT_STATE_OFF; +#ifdef USE_ESP32 + // Idempotent — only allocates DMA buffers + netifs on the first call. After this, + // start() can safely run. + this->wifi_lazy_init_(); +#endif this->start(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0437267a1f..d0521e548a 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -694,6 +694,12 @@ class WiFiComponent final : public Component { bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of + // DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent. + // Called from setup() only when enable_on_boot_=true, and from enable() on first use. + void wifi_lazy_init_(); +#endif WiFiSTAConnectStatus wifi_sta_connect_status_() const; bool is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && @@ -889,6 +895,12 @@ class WiFiComponent final : public Component { bool rrm_{false}; #endif bool enable_on_boot_{true}; +#ifdef USE_ESP32 + // Tracks whether esp_wifi_init() + netif creation has happened. Allows enable() + // to be called at runtime without re-allocating, and ensures the heavy init is + // skipped entirely when enable_on_boot_ is false until first enable(). + bool wifi_initialized_{false}; +#endif bool got_ipv4_address_{false}; bool keep_scan_results_{false}; bool has_completed_scan_after_captive_portal_start_{ diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 11b39b5000..b395c77141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -163,11 +163,26 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err)); return; } + // NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of + // DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on + // memory-tight devices. They are now deferred to wifi_lazy_init_(), called from + // setup() when enable_on_boot_ is true, or from enable() on first runtime enable. + // This makes enable_on_boot:false genuinely skip the wifi DMA allocation. +} - s_sta_netif = esp_netif_create_default_wifi_sta(); +void WiFiComponent::wifi_lazy_init_() { + if (this->wifi_initialized_) + return; + + // Guard each creation so partial init (e.g. a failed esp_wifi_init() below) + // followed by a retry via enable() does not leak the existing netif handle + // nor re-register the default WiFi handlers. + if (s_sta_netif == nullptr) + s_sta_netif = esp_netif_create_default_wifi_sta(); #ifdef USE_WIFI_AP - s_ap_netif = esp_netif_create_default_wifi_ap(); + if (s_ap_netif == nullptr) + s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); @@ -175,7 +190,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGW(TAG, "starting wifi without nvs"); cfg.nvs_enable = false; } - err = esp_wifi_init(&cfg); + esp_err_t err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); return; @@ -185,6 +200,7 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; } bool WiFiComponent::wifi_mode_(optional sta, optional ap) { diff --git a/tests/components/wifi/test-lifecycle.esp32-idf.yaml b/tests/components/wifi/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..229a24b2d1 --- /dev/null +++ b/tests/components/wifi/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + enable_on_boot: false + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + wifi.enabled: + then: + - wifi.enable: From 2454ad1645321a8cde03f5a0e167c6f2b0ed5b2c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 15:30:07 -0500 Subject: [PATCH 191/282] [ethernet] Add enable_on_boot lifecycle + lazy-init to reclaim DMA-capable SRAM (#16607) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 28 +++++++ esphome/components/ethernet/automation.h | 30 ++++++++ .../components/ethernet/ethernet_component.h | 32 ++++++++ .../ethernet/ethernet_component_esp32.cpp | 76 ++++++++++++++++++- .../ethernet/ethernet_component_rp2040.cpp | 17 +++++ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ethernet/automation.h create mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 22f5eb33e1..784f5dee8c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging from esphome import automation, pins +from esphome.automation import Condition import esphome.codegen as cg from esphome.components.network import ip_address_literal from esphome.config_helpers import filter_source_files_from_platform @@ -13,6 +14,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -217,6 +219,10 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) +EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -348,6 +354,7 @@ BASE_SCHEMA = cv.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, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } @@ -494,6 +501,9 @@ async def to_code(config): cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: @@ -715,3 +725,21 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files + + +async def _new_pvariable_to_code(config, id_, template_arg, args): + return cg.new_Pvariable(id_, template_arg) + + +for _name, _cls in ( + ("ethernet.connected", EthernetConnectedCondition), + ("ethernet.enabled", EthernetEnabledCondition), +): + automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) +for _name, _cls in ( + ("ethernet.enable", EthernetEnableAction), + ("ethernet.disable", EthernetDisableAction), +): + automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( + _new_pvariable_to_code + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 0000000000..c16abc5bda --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#include "ethernet_component.h" + +namespace esphome::ethernet { + +template class EthernetConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_connected(); } +}; + +template class EthernetEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } +}; + +template class EthernetEnableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->enable(); } +}; + +template class EthernetDisableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->disable(); } +}; + +} // namespace esphome::ethernet +#endif diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 17c84ee954..7d06377f90 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -124,6 +124,17 @@ class EthernetComponent final : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled). + // enable_on_boot defaults to true; when false, setup() runs all the driver/netif + // installation but skips esp_eth_start(), keeping the link cold until enable() is + // called. This is the primary lever for memory reclamation in multi-interface + // configurations where only one interface should carry traffic at a time. + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void enable(); + void disable(); + bool is_disabled() { return this->disabled_; } + bool is_enabled() { return !this->disabled_; } + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); @@ -194,6 +205,16 @@ class EthernetComponent final : public Component { void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver + // install, netif attach, and event handler registration (which together allocate + // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue) + // until ethernet actually needs to come up. Idempotent — guarded by the + // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or + // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi. + void ethernet_lazy_init_(); +#endif + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void notify_ip_state_listeners_(); #endif @@ -287,6 +308,17 @@ class EthernetComponent final : public Component { bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; + // Codegen-time YAML option. When false, setup() defers esp_eth_start(). + bool enable_on_boot_{true}; + // Mirror of "is the link intentionally stopped" — set when setup() honors + // enable_on_boot=false, cleared by enable(), set again by disable(). + bool disabled_{false}; +#ifdef USE_ESP32 + // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable() + // to be called at runtime after enable_on_boot:false without re-allocating, and + // ensures setup() skips the heavy init when enable_on_boot_ is false. + bool ethernet_initialized_{false}; +#endif #if LWIP_IPV6 uint8_t ipv6_count_{0}; bool ipv6_setup_done_{false}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 6481c8c1f4..544ec79c32 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -138,6 +138,24 @@ void EthernetComponent::setup() { delay(300); // NOLINT } + if (this->enable_on_boot_) { + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do. + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)"); + this->disabled_ = true; + } +} + +void EthernetComponent::ethernet_lazy_init_() { + if (this->ethernet_initialized_) + return; + esp_err_t err; #ifdef USE_ETHERNET_SPI @@ -371,9 +389,41 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); #endif /* USE_NETWORK_IPV6 */ - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); + this->ethernet_initialized_ = true; +} + +void EthernetComponent::enable() { + if (!this->disabled_) + return; + + ESP_LOGD(TAG, "Enabling"); + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + ESP_LOGE(TAG, "Cannot enable - init failed"); + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err)); + return; + } + this->disabled_ = false; + // The ETH_EVENT_START handler will set started_=true; the loop state machine + // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions. + this->enable_loop(); +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + + ESP_LOGD(TAG, "Disabling"); + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err)); + } + this->disabled_ = true; + // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED. } void EthernetComponent::dump_config() { @@ -487,6 +537,8 @@ void EthernetComponent::dump_config() { network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; + if (!this->ethernet_initialized_) + return addresses; // all-zero IPs esp_netif_ip_info_t ip; esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); if (err != ESP_OK) { @@ -709,6 +761,10 @@ void EthernetComponent::start_connect_() { } void EthernetComponent::dump_connect_params_() { + if (!this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " uninitialized/disabled"); + return; + } esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); const ip_addr_t *dns_ip1; @@ -776,6 +832,16 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy #endif void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (!this->ethernet_initialized_) { + // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless + // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. + if (this->fixed_mac_.has_value()) { + memcpy(mac, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac, ESP_MAC_ETH); + } + return; + } esp_err_t err; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); @@ -795,6 +861,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( } eth_duplex_t EthernetComponent::get_duplex_mode() { + if (!this->ethernet_initialized_) + return ETH_DUPLEX_HALF; esp_err_t err; eth_duplex_t duplex_mode; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); @@ -803,6 +871,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() { } eth_speed_t EthernetComponent::get_link_speed() { + if (!this->ethernet_initialized_) + return ETH_SPEED_10M; esp_err_t err; eth_speed_t speed; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index ef7bd46332..250297ddb5 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -361,6 +361,23 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::enable() { + // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; + // there is no clean enable/disable hook today. The YAML option is accepted on + // RP2040 for schema parity but has no effect. + if (!this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = false; +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = true; +} + } // namespace esphome::ethernet #endif // USE_ETHERNET && USE_RP2040 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..904a916789 --- /dev/null +++ b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,39 @@ +ethernet: + id: eth + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + enable_on_boot: false + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); From ab46f8bd7451d8f058bfd317096cbcd6bb74fbf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 192/282] [api] Fix crash loop on VoiceAssistantConfigurationRequest (#16757) --- esphome/components/api/api_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c880e036cb..2b1458e2ae 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1306,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } From d7d20f4f6bd76a8c7a2575da0c3d99772d41dbd5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:04:35 +1000 Subject: [PATCH 193/282] [cli] Allow state reporting control via env (#16746) --- esphome/__main__.py | 50 +++++++++++++++++++++------ tests/unit_tests/test_main.py | 64 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc179ebf98..47dd8d273c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1351,6 +1351,19 @@ def _validate_bootloader_binary(binary: Path) -> None: ) +def _should_subscribe_states(args: ArgsProtocol) -> bool: + """Determine whether entity state changes should be shown in log output. + + The ``--states``/``--no-states`` command line flags take precedence. When + neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls + the behavior, defaulting to showing states. + """ + states = getattr(args, "states", None) + if states is not None: + return states + return get_bool_env("ESPHOME_LOG_STATES", True) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -1380,7 +1393,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_logs( config, network_devices, - subscribe_states=not getattr(args, "no_states", False), + subscribe_states=_should_subscribe_states(args), ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): @@ -2019,6 +2032,29 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_states_args(parser: argparse.ArgumentParser) -> None: + """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. + + When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable + controls whether entity state changes are shown (defaulting to showing them). + """ + states_group = parser.add_mutually_exclusive_group() + states_group.add_argument( + "--states", + dest="states", + action="store_true", + default=None, + help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).", + ) + states_group.add_argument( + "--no-states", + dest="states", + action="store_false", + default=None, + help="Do not show entity state changes in log output.", + ) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2195,11 +2231,7 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_logs.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_logs) parser_discover = subparsers.add_parser( "discover", @@ -2231,11 +2263,7 @@ def parse_args(argv): "--no-logs", help="Disable starting logs.", action="store_true" ) - parser_run.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_run) parser_run.add_argument( "--reset", diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 26b550669f..8cce60d351 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1269,6 +1269,7 @@ class MockArgs: ota_platform: str | None = None partition_table: bool = False bootloader: bool = False + states: bool | None = None def test_upload_program_serial_esp32( @@ -2663,7 +2664,7 @@ def test_show_logs_api_no_states( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False devices = ["192.168.1.100"] result = show_logs(CORE.config, args, devices) @@ -5989,19 +5990,68 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( def test_parse_args_run_no_states() -> None: """Test that --no-states is parsed for the run command.""" args = parse_args(["esphome", "run", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False -def test_parse_args_run_no_states_default() -> None: - """Test that no_states defaults to False for the run command.""" +def test_parse_args_run_states() -> None: + """Test that --states is parsed for the run command.""" + args = parse_args(["esphome", "run", "--states", "device.yaml"]) + assert args.states is True + + +def test_parse_args_run_states_default() -> None: + """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) - assert args.no_states is False + assert args.states is None def test_parse_args_logs_no_states() -> None: """Test that --no-states is parsed for the logs command.""" args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False + + +def test_parse_args_logs_states() -> None: + """Test that --states is parsed for the logs command.""" + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + assert args.states is True + + +def test_should_subscribe_states_default() -> None: + """Test that states are shown by default when nothing is set.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ESPHOME_LOG_STATES", None) + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_env_suppresses() -> None: + """Test that ESPHOME_LOG_STATES=false suppresses states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is False + + +def test_should_subscribe_states_flag_overrides_env() -> None: + """Test that --states overrides ESPHOME_LOG_STATES=false.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_no_flag_overrides_env() -> None: + """Test that --no-states overrides ESPHOME_LOG_STATES=true.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is False @patch("esphome.components.api.client.run_logs") @@ -6020,7 +6070,7 @@ def test_command_run_passes_no_states_to_show_logs( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False args.no_logs = False args.device = None From 1740e541053b007fd47d1504a42dfe1443ad055a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 1 Jun 2026 20:20:18 -0700 Subject: [PATCH 194/282] [ci] Fix auto label platform restructure false positive (#16734) Co-authored-by: Claude --- .github/scripts/auto-label-pr/detectors.js | 8 + .github/scripts/auto-label-pr/package.json | 7 + .../auto-label-pr/tests/detectors.test.js | 147 ++++++++++++++++++ .github/workflows/ci-github-scripts.yml | 27 ++++ 4 files changed, 189 insertions(+) create mode 100644 .github/scripts/auto-label-pr/package.json create mode 100644 .github/scripts/auto-label-pr/tests/detectors.test.js create mode 100644 .github/workflows/ci-github-scripts.yml diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 410c1a53c0..81bb77843d 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -107,6 +107,8 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/, ]; + const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename)); + for (const file of addedFiles) { for (const re of platformPathPatterns) { const match = file.match(re); @@ -114,6 +116,12 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { const platform = match[2]; if (!apiData.platformComponents.includes(platform)) break; + // Skip if this is a restructure between flat and subdirectory forms (either direction): + // /.py <-> //__init__.py + const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`; + const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`; + if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break; + labels.add('new-platform'); const content = await fetchPrFileContent(github, context, file); if (content === null) { diff --git a/.github/scripts/auto-label-pr/package.json b/.github/scripts/auto-label-pr/package.json new file mode 100644 index 0000000000..401b376db6 --- /dev/null +++ b/.github/scripts/auto-label-pr/package.json @@ -0,0 +1,7 @@ +{ + "name": "auto-label-pr", + "private": true, + "scripts": { + "test": "node --test tests/*.test.js" + } +} diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js new file mode 100644 index 0000000000..02d69ca95e --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -0,0 +1,147 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectNewPlatforms, detectNewComponents } = require('../detectors'); + +// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents +// to check for CONFIG_SCHEMA in newly added files. +function makeGithub(content = '') { + return { + rest: { + repos: { + getContent: async () => ({ + data: { content: Buffer.from(content).toString('base64') } + }) + } + } + }; +} + +const CONTEXT = { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } } +}; + +const API_DATA = { + targetPlatforms: ['esp32', 'esp8266', 'rp2040'], + platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve'] +}; + +const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; +const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; + +// --------------------------------------------------------------------------- +// detectNewPlatforms +// --------------------------------------------------------------------------- + +describe('detectNewPlatforms', () => { + describe('restructure detection (no false positives)', () => { + it('flat .py -> subdir __init__.py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + + it('subdir __init__.py -> flat .py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); + + describe('genuine new platforms', () => { + it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('non-platform file addition produces no labels', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/sensor.py', status: 'added' }, + ]; + // Override platformComponents so 'sensor' is not a recognized platform -> no label expected. + const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] }; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectNewComponents +// --------------------------------------------------------------------------- + +describe('detectNewComponents', () => { + it('new top-level __init__.py sets new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/actuator/__init__.py', status: 'added', }, + ]; + const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_component/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => { + const prFiles = [ + { filename: 'esphome/components/my_platform/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.ok(result.labels.has('new-target-platform')); + }); + + it('modified __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/existing/__init__.py', status: 'modified' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); + + it('nested __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); +}); diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml new file mode 100644 index 0000000000..6713fcc454 --- /dev/null +++ b/.github/workflows/ci-github-scripts.yml @@ -0,0 +1,27 @@ +name: CI - GitHub Scripts + +on: + push: + branches: [dev, beta, release] + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + +permissions: + contents: read + +jobs: + test-auto-label-pr: + name: Test auto-label-pr scripts + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run tests + working-directory: .github/scripts/auto-label-pr + run: npm test From 063770bcf403f6a8f91b59ac85112405f36ec385 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 2 Jun 2026 09:32:27 -0400 Subject: [PATCH 195/282] [i2s_audio] Fix speaker DMA buffer sizing and validate bit depth at compile time (#16672) --- esphome/components/i2s_audio/__init__.py | 2 +- .../components/i2s_audio/speaker/__init__.py | 33 ++++++----- .../speaker/i2s_audio_speaker_standard.cpp | 55 +++++++++++++++++-- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 951b8c0498..8e432695a1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -170,7 +170,7 @@ def i2s_audio_component_schema( min=1 ), cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( - _validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE) + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) ), cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( *I2S_MODE_OPTIONS, lower=True diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 8215d8b518..5ba2f4b1a5 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -98,11 +98,19 @@ def _set_stream_limits(config): min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), )(config) - elif config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode has modifiable stream settings + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. + min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + + if config[CONF_I2S_MODE] == CONF_PRIMARY: + # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the + # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the + # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=32, + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=16000, @@ -111,13 +119,13 @@ def _set_stream_limits(config): else: # Secondary mode has unmodifiable max bits per sample and min/max sample rates audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), - ) + )(config) return config @@ -134,12 +142,11 @@ def _validate_esp32_variant(config): if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: raise cv.Invalid(f"{variant} does not have an internal DAC") - elif ( - variant == esp32.VARIANT_ESP32 - and config.get(CONF_BITS_PER_SAMPLE) == 8 - and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT) - ): - raise cv.Invalid("8-bit mono mode is not supported on ESP32") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") return config 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 ffe901504d..0afb67fb36 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include +#include #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" @@ -16,8 +17,16 @@ namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; -static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep // invariant between i2s_event_queue_ and write_records_queue_). @@ -27,6 +36,17 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; // without masking real failures. static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + void I2SAudioSpeaker::dump_config() { I2SAudioSpeakerBase::dump_config(); const char *fmt_str; @@ -57,8 +77,21 @@ void I2SAudioSpeaker::run_speaker_task() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; - const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it + // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through + // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes; + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count. + dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; + } else { + // Should not happen for a READY channel; fall back to the requested size. + dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + } + const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); bool successful_setup = false; @@ -308,12 +341,24 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } +#ifdef USE_ESP32_VARIANT_ESP32 + // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample + // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples + // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple + // of 16, so reject anything else rather than emit corrupted audio. + if (audio_stream_info.get_bits_per_sample() % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", + (unsigned) audio_stream_info.get_bits_per_sample()); + return ESP_ERR_NOT_SUPPORTED; + } +#endif // USE_ESP32_VARIANT_ESP32 + if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; From 792e1ff30466d798805a75ea0224847da98cab9b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:12:50 +1200 Subject: [PATCH 196/282] [i2c] Add basic host platform support (#14489) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/i2c/__init__.py | 119 +++++-- esphome/components/i2c/i2c_bus_host.cpp | 297 ++++++++++++++++++ esphome/components/i2c/i2c_bus_host.h | 41 +++ tests/components/i2c/test.host.yaml | 4 + .../common/i2c/host.yaml | 7 + 5 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 esphome/components/i2c/i2c_bus_host.cpp create mode 100644 esphome/components/i2c/i2c_bus_host.h create mode 100644 tests/components/i2c/test.host.yaml create mode 100644 tests/test_build_components/common/i2c/host.yaml diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 1684f479ba..d9dd6d5ee2 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,4 +1,6 @@ import logging +import re +import sys from esphome import pins import esphome.codegen as cg @@ -29,6 +31,7 @@ from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_DEVICE, CONF_FREQUENCY, CONF_I2C, CONF_I2C_ID, @@ -40,6 +43,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2040, PlatformFramework, @@ -56,6 +60,7 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus) ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component) IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) +HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") ESP32_I2C_CAPABILITIES = { @@ -83,6 +88,12 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True +def validate_device(value): + if not re.match(r"^/(?:[^/]+/)*[^/]+$", value): + raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)") + return value + + def _bus_declare_type(value): if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) @@ -90,6 +101,8 @@ def _bus_declare_type(value): return cv.declare_id(ArduinoI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) + if CORE.is_host: + return cv.declare_id(HostI2CBus)(value) raise NotImplementedError @@ -121,15 +134,48 @@ def validate_config(config): return config +def validate_host_config(config): + if CORE.is_host: + # Host I2C is currently only supported on Linux + if not sys.platform.lower().startswith("linux"): + raise cv.Invalid( + "I2C is only supported on Linux for the host platform. " + f"Current platform: {sys.platform}" + ) + if CONF_SDA in config or CONF_SCL in config: + raise cv.Invalid( + "'sda' and 'scl' are not supported on host platform; use 'device' instead." + ) + if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config: + raise cv.Invalid("Pull-up configuration is not supported on host platform.") + if CONF_DEVICE not in config: + raise cv.Invalid( + "'device' is required for host platform (e.g., /dev/i2c-0)." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): _bus_declare_type, - cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SDA, + esp32="SDA", + esp8266="SDA", + rp2040="SDA", + nrf52="SDA", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), - cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SCL, + esp32="SCL", + esp8266="SCL", + rp2040="SCL", + nrf52="SCL", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), @@ -139,6 +185,7 @@ CONFIG_SCHEMA = cv.All( esp8266="50kHz", rp2040="50kHz", nrf52="100kHz", + host="50kHz", ): cv.All( cv.frequency, cv.float_range(min=0, min_included=False), @@ -155,10 +202,22 @@ CONFIG_SCHEMA = cv.All( ), cv.boolean, ), + cv.Optional(CONF_DEVICE): cv.All( + cv.only_on(PLATFORM_HOST), validate_device + ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, + PLATFORM_NRF52, + PLATFORM_HOST, + ] + ), validate_config, + validate_host_config, ) @@ -217,7 +276,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") - if CORE.using_zephyr: + if CORE.is_host: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_device(config[CONF_DEVICE])) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + elif CORE.using_zephyr: zephyr_add_prj_conf("I2C", True) i2c = "i2c0" if zephyr_data()[KEY_BOARD] == "xiao_ble": @@ -244,25 +309,40 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))") ) + await cg.register_component(var, config) + + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) else: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) + await cg.register_component(var, config) - cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) - cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) - cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) - cg.add(var.set_scan(config[CONF_SCAN])) - if CONF_TIMEOUT in config: - cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) - if CORE.using_arduino and not CORE.is_esp32: - cg.add_library("Wire", None) - if CONF_LOW_POWER_MODE in config: - cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CORE.using_arduino and not CORE.is_esp32: + cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): @@ -365,5 +445,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp new file mode 100644 index 0000000000..17279fda50 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -0,0 +1,297 @@ +#ifdef USE_HOST +#if defined(__linux__) + +#include "i2c_bus_host.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::i2c { + +static const char *const TAG = "i2c.host"; + +HostI2CBus::~HostI2CBus() { + if (this->file_descriptor_ != -1) { + close(this->file_descriptor_); + this->file_descriptor_ = -1; + } +} + +void HostI2CBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up I2C bus..."); + + // Open I2C device file + this->file_descriptor_ = open(this->device_.c_str(), O_RDWR); + if (this->file_descriptor_ == -1) { + int err = errno; + if (err == ENOENT) { + this->update_error_("not found"); + } else if (err == EACCES) { + this->update_error_("permission denied"); + } else { + this->update_error_(std::string("failed to open: ") + strerror(err)); + } + this->mark_failed(); + return; + } + + this->initialized_ = true; + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + + // Run bus scan if enabled + if (this->scan_) { + this->i2c_scan_(); + } +} + +void HostI2CBus::dump_config() { + ESP_LOGCONFIG(TAG, "I2C Bus:"); + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + // Bus frequency cannot be set from userspace via i2c-dev; report it as informational only + ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_); + + if (!this->first_error_.empty()) { + ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str()); + } + + if (this->scan_) { + ESP_LOGI(TAG, " Scan Results:"); + for (const auto &s : this->scan_results_) { + if (s.second) { + ESP_LOGI(TAG, " 0x%02X: Found", s.first); + } + } + } +} + +ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + if (!this->initialized_) { + ESP_LOGE(TAG, "I2C bus not initialized"); + return ERROR_NOT_INITIALIZED; + } + + ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count); + + // Handle special case: probe (no write data, no read data) + // This is used for device detection during bus scanning + if (write_count == 0 && read_count == 0) { + struct i2c_msg msg; + msg.addr = address; + msg.flags = 0; + msg.len = 0; + msg.buf = nullptr; + + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = &msg; + rdwr_data.nmsgs = 1; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + // If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses) + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address); + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Use I2C_SMBUS ioctl with Quick command + union i2c_smbus_data data; + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = 0; + args.size = I2C_SMBUS_QUICK; + args.data = &data; + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + return this->map_errno_to_error_code_(errno); + } + return ERROR_OK; + } + return this->map_errno_to_error_code_(err); + } + return ERROR_OK; + } + + // i2c_msg.len is a 16-bit field; reject transfers that would silently truncate + if (write_count > UINT16_MAX || read_count > UINT16_MAX) { + ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count, + (unsigned) UINT16_MAX); + return ERROR_TOO_LARGE; + } + + // Prepare messages for combined write-read transaction + struct i2c_msg msgs[2]; + int num_msgs = 0; + + // Add write message if write data present + if (write_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = 0; // Write + msgs[num_msgs].len = write_count; + msgs[num_msgs].buf = const_cast(write_buffer); + num_msgs++; + } + + // Add read message if read data requested + if (read_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = I2C_M_RD; // Read + msgs[num_msgs].len = read_count; + msgs[num_msgs].buf = read_buffer; + num_msgs++; + } + + // Execute I2C transaction + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = msgs; + rdwr_data.nmsgs = num_msgs; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Perform write if needed + if (write_count > 0) { + ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count); + if (written != (ssize_t) write_count) { + int write_err = errno; + // If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (write_err == EOPNOTSUPP || write_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes + // Standard SMBus mapping: first byte is command, remaining bytes are data + if (write_count < 1) { + ESP_LOGE(TAG, "Write size too small for I2C_SMBUS"); + return ERROR_INVALID_ARGUMENT; + } + if (write_count > I2C_SMBUS_BLOCK_MAX + 1) { + ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + // Standard SMBus: first byte = command, rest = data + uint8_t command = write_buffer[0]; + size_t data_len = write_count - 1; + data.block[0] = data_len; + if (data_len > 0) { + memcpy(&data.block[1], write_buffer + 1, data_len); + } + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = command; + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + } else { + ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err)); + return this->map_errno_to_error_code_(write_err); + } + } + } + // Perform read if needed + if (read_count > 0) { + ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count); + if (bytes_read != (ssize_t) read_count) { + int read_err = errno; + // If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (read_err == EOPNOTSUPP || read_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes + if (read_count > I2C_SMBUS_BLOCK_MAX) { + ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + data.block[0] = read_count; + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_READ; + args.command = 0; // Start register/command + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + // I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0]; + // a short read means we did not receive all requested bytes + if (data.block[0] < read_count) { + ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count); + return ERROR_NOT_ACKNOWLEDGED; + } + // Copy data from SMBus buffer to output buffer + memcpy(read_buffer, &data.block[1], read_count); + } else { + ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err)); + return this->map_errno_to_error_code_(read_err); + } + } + } + ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT + return ERROR_OK; + } + ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err)); + return this->map_errno_to_error_code_(err); + } + + ESP_LOGVV(TAG, "I2C transaction successful"); + return ERROR_OK; +} + +ErrorCode HostI2CBus::map_errno_to_error_code_(int err) { + switch (err) { + case ENXIO: + return ERROR_NOT_ACKNOWLEDGED; + case ETIMEDOUT: + return ERROR_TIMEOUT; + case EINVAL: + return ERROR_INVALID_ARGUMENT; + case ENODEV: + case ENOTTY: + return ERROR_NOT_INITIALIZED; + case EOPNOTSUPP: + case ENOSYS: + // Operation not supported - some I2C adapters don't support zero-length transactions + ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)"); + return ERROR_NOT_ACKNOWLEDGED; + default: + ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err)); + return ERROR_UNKNOWN; + } +} + +void HostI2CBus::update_error_(const std::string &error) { + if (this->first_error_.empty()) { + this->first_error_ = error; + } + ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str()); +} + +} // namespace esphome::i2c + +#else +#error "HostI2CBus is only supported on Linux" +#endif // defined(__linux__) +#endif // USE_HOST diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h new file mode 100644 index 0000000000..8e3aff7977 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.h @@ -0,0 +1,41 @@ +#pragma once + +#ifdef USE_HOST + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "i2c_bus.h" + +namespace esphome::i2c { + +class HostI2CBus : public I2CBus, public Component { + public: + ~HostI2CBus() override; + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; + + void set_device(const std::string &device) { this->device_ = device; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + + const std::string &get_device() const { return this->device_; } + + protected: + void update_error_(const std::string &error); + ErrorCode map_errno_to_error_code_(int err); + + std::string device_; + uint32_t frequency_{50000}; + int file_descriptor_{-1}; + bool initialized_{false}; + std::string first_error_; +}; + +} // namespace esphome::i2c + +#endif // USE_HOST diff --git a/tests/components/i2c/test.host.yaml b/tests/components/i2c/test.host.yaml new file mode 100644 index 0000000000..6ae617e230 --- /dev/null +++ b/tests/components/i2c/test.host.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/i2c/host.yaml b/tests/test_build_components/common/i2c/host.yaml new file mode 100644 index 0000000000..00bad206d8 --- /dev/null +++ b/tests/test_build_components/common/i2c/host.yaml @@ -0,0 +1,7 @@ +# Common I2C configuration for host platform tests + +i2c: + - id: i2c_bus + device: /dev/i2c-0 + frequency: 100kHz + scan: true From 997ab116876c73ccc14c61f5e0735d6050f7671a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:21:33 +1000 Subject: [PATCH 197/282] [lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702) --- esphome/components/display/__init__.py | 102 ++++++++-- esphome/components/lvgl/__init__.py | 116 ++++++------ esphome/components/mipi_dsi/display.py | 17 +- esphome/components/mipi_rgb/display.py | 17 +- esphome/components/mipi_spi/display.py | 28 ++- .../display/test_display_metadata.py | 130 ++++++++++--- tests/component_tests/lvgl/test_validation.py | 177 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 106 ++++------- 8 files changed, 520 insertions(+), 173 deletions(-) create mode 100644 tests/component_tests/lvgl/test_validation.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 744b5d16c4..7a66da11f2 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -3,11 +3,18 @@ from dataclasses import dataclass from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components.const import KEY_METADATA +from esphome.components.const import ( + BYTE_ORDER_BIG, + CONF_BYTE_ORDER, + CONF_DRAW_ROUNDING, + KEY_METADATA, +) import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, + CONF_DIMENSIONS, CONF_FROM, + CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_PAGE_ID, @@ -16,10 +23,11 @@ from esphome.const import ( CONF_TO, CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, + CONF_WIDTH, SCHEDULER_DONT_RUN, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.final_validate import full_config DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -159,29 +167,97 @@ async def setup_display_core_(var, config): class DisplayMetaData: width: int = 0 height: int = 0 - has_writer: bool = False has_hardware_rotation: bool = False + byte_order: str = BYTE_ORDER_BIG + has_writer: bool = False + rotation: int = 0 + draw_rounding: int = 0 + + +def _get_metadata_list() -> list[tuple]: + """Get the raw metadata list. Each entry is (id, DisplayMetaData).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, []) def get_all_display_metadata() -> dict[str, DisplayMetaData]: - """Get all display metadata.""" - return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + """Get all display metadata as a dict keyed by resolved ID strings. + + Must not be called before IDs have been finalised. + """ + entries = _get_metadata_list() + assert all(id_.id is not None for id_, _ in entries), ( + "get_all_display_metadata called before display IDs have been resolved" + ) + return {id_.id: meta for id_, meta in entries} -def get_display_metadata(display_id: str) -> DisplayMetaData | None: - """Get display metadata by ID for use by other components.""" - return get_all_display_metadata().get(display_id, DisplayMetaData()) +def get_display_metadata(display_id: ID) -> DisplayMetaData: + """Get display metadata by ID object + + Must not be called before IDs have been finalised. + """ + for id_, meta in _get_metadata_list(): + if id_ is display_id: + return meta + assert id_.id is not None, ( + "get_display_metadata called before display IDs have been resolved" + ) + if id_.id == display_id.id: + return meta + # No metadata found, display driver may not yet support it. + # Read the raw config to populate the returned data + global_config = full_config.get() + path = global_config.get_path_for_id(display_id)[:-1] + disp_config = global_config.get_config_for_path(path) + dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0)) + if isinstance(dimensions, dict): + dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0)) + elif not isinstance(dimensions, tuple) or len(dimensions) != 2: + dimensions = (0, 0) + + meta = DisplayMetaData( + width=dimensions[0], + height=dimensions[1], + has_hardware_rotation=False, + byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED), + has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True + or disp_config.get(CONF_PAGES) is not None + or disp_config.get(CONF_LAMBDA) is not None + or disp_config.get(CONF_SHOW_TEST_CARD) is True, + rotation=disp_config.get(CONF_ROTATION, 0), + draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0), + ) + _get_metadata_list().append((display_id, meta)) + return meta def add_metadata( - id: str | MockObj, + id: ID, width: int, height: int, - has_writer: bool, has_hardware_rotation: bool = False, + byte_order: str = BYTE_ORDER_BIG, + has_writer: bool = False, + rotation: int = 0, + draw_rounding: int = 0, ): - get_all_display_metadata()[str(id)] = DisplayMetaData( - width, height, has_writer, has_hardware_rotation + entries = _get_metadata_list() + assert not any(existing_id is id for existing_id, _ in entries), ( + f"Duplicate display metadata for ID {id}" + ) + entries.append( + ( + id, + DisplayMetaData( + width=width, + height=height, + has_hardware_rotation=has_hardware_rotation, + byte_order=byte_order, + has_writer=has_writer, + rotation=rotation, + draw_rounding=draw_rounding, + ), + ) ) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e005f897e..022d629960 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -7,6 +7,7 @@ import re from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( + BYTE_ORDER_BIG, CONF_BYTE_ORDER, CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, @@ -30,12 +31,10 @@ from esphome.components.image import ( from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( - CONF_AUTO_CLEAR_ENABLED, CONF_BUFFER_SIZE, CONF_ESPHOME, CONF_GROUP, CONF_ID, - CONF_LAMBDA, CONF_LOG_LEVEL, CONF_ON_IDLE, CONF_PAGES, @@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]): def final_validation(config_list): - if len(config_list) != 1: - multi_conf_validate(config_list) global_config = full_config.get() + # Resolve byte_order from display metadata before multi-config validation for config in config_list: + metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]] + if any(m.has_writer for m in metas): + raise cv.Invalid( + "Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL" + ) + if any(m.rotation != 0 for m in metas): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" + ) + config[CONF_DRAW_ROUNDING] = max( + [m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]] + ) + display_byte_orders = { + m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED + } + if len(display_byte_orders) > 1: + raise cv.Invalid( + "All displays configured for an LVGL instance must use the same byte_order" + ) + if display_byte_orders: + display_order = next(iter(display_byte_orders)) + if CONF_BYTE_ORDER in config: + if config[CONF_BYTE_ORDER] != display_order: + raise cv.Invalid( + "LVGL byte order must match the display byte order", + [CONF_BYTE_ORDER], + ) + else: + config[CONF_BYTE_ORDER] = display_order + if CONF_BYTE_ORDER not in config: + config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG + if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") - for display_id in config[df.CONF_DISPLAYS]: - path = global_config.get_path_for_id(display_id)[:-1] - display = global_config.get_config_for_path(path) - if CONF_LAMBDA in display or CONF_PAGES in display: - raise cv.Invalid( - "Using lambda: or pages: in display config is not compatible with LVGL" - ) - # treating 0 as false is intended here. - if display.get(CONF_ROTATION): - raise cv.Invalid( - "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" - ) - if display.get(CONF_AUTO_CLEAR_ENABLED) is True: - raise cv.Invalid( - "Using auto_clear_enabled: true in display config not compatible with LVGL" - ) - if draw_rounding := display.get(CONF_DRAW_ROUNDING): - config[CONF_DRAW_ROUNDING] = max( - draw_rounding, config[CONF_DRAW_ROUNDING] - ) buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in get_focused_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if ( - df.CONF_ADJUSTABLE in widget_conf - and not widget_conf[df.CONF_ADJUSTABLE] - ): - raise cv.Invalid( - "A non adjustable arc may not be focused", - path, - ) - for w in get_refreshed_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): - raise cv.Invalid( - f"Widget '{w}' does not have any dynamic properties to refresh", - ) - # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_updated_widgets().items(): - for conf in update_configs: - for id_conf in conf.get(CONF_ID, ()): - name = id_conf[CONF_ID] - path = global_config.get_path_for_id(name) - widget_conf = global_config.get_config_for_path(path[:-1]) - widget_type.final_validate(name, conf, widget_conf, path[1:]) + + if len(config_list) != 1: + multi_conf_validate(config_list) + + for w in get_focused_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]: + raise cv.Invalid( + "A non adjustable arc may not be focused", + path, + ) + for w in get_refreshed_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): + raise cv.Invalid( + f"Widget '{w}' does not have any dynamic properties to refresh", + ) + # Do per-widget type final validation for update actions + for widget_type, update_configs in df.get_updated_widgets().items(): + for conf in update_configs: + for id_conf in conf.get(CONF_ID, ()): + name = id_conf[CONF_ID] + path = global_config.get_path_for_id(name) + widget_conf = global_config.get_config_for_path(path[:-1]) + widget_type.final_validate(name, conf, widget_conf, path[1:]) async def to_code(configs): @@ -367,8 +378,7 @@ async def to_code(configs): # options will have CONF_ROTATION true if rotation is changed in an automation. if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True: if all( - get_display_metadata(str(disp)).has_hardware_rotation - for disp in displays + get_display_metadata(disp).has_hardware_rotation for disp in displays ): rotation_type = RotationType.ROTATION_HARDWARE df.LOGGER.info("LVGL will use hardware rotation via display driver") @@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), - cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of( + cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 026c214569..3554e32299 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -37,6 +37,7 @@ from esphome.components.mipi import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, CONF_DISABLED, @@ -167,7 +168,21 @@ def _config_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + config = model_schema(config)(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=False, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config def _final_validate(config): diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4952bda95f..b38ddad491 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BLUE, CONF_COLOR_ORDER, CONF_CS_PIN, @@ -226,11 +227,25 @@ def _config_schema(config): extra=cv.ALLOW_EXTRA, )(config) schema = model_schema(config) - return cv.All( + config = cv.All( schema, cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + model.rotation_as_transform(config), + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 364ada9046..3c5a84594e 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BRIGHTNESS, CONF_BUFFER_SIZE, CONF_COLOR_ORDER, @@ -47,6 +48,7 @@ from esphome.const import ( CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, + CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, @@ -267,6 +269,28 @@ def customise_schema(config): if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config: raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) + model = MODELS[config[CONF_MODEL]] + has_hardware_transform = config.get( + CONF_TRANSFORM + ) != CONF_DISABLED and model.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + width, height, _offset_width, _offset_height = model.get_dimensions( + config, not has_hardware_transform + ) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_transform, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) return config @@ -338,7 +362,6 @@ def get_instance(config): buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) madctl = model.get_madctl(model.get_base_transform(config), config) - has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, @@ -352,9 +375,6 @@ def get_instance(config): madctl, has_hardware_transform, ] - display.add_metadata( - config[CONF_ID], width, height, has_writer, has_hardware_transform - ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index ef3f12cb73..befb019612 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -4,77 +4,145 @@ from unittest.mock import patch import pytest +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE from esphome.components.display import ( DisplayMetaData, add_metadata, get_all_display_metadata, get_display_metadata, ) -from esphome.cpp_generator import MockObj +from esphome.config import Config +from esphome.core import ID +from esphome.final_validate import full_config -def test_add_metadata_with_string_id(): - """Test adding metadata with a plain string ID.""" +def test_add_metadata_basic(): + """Test adding metadata with an ID object.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("my_display", 320, 240, True) - meta = get_display_metadata("my_display") + add_metadata(ID("my_display"), 320, 240) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=320, height=240, has_writer=True, has_hardware_rotation=False + width=320, + height=240, + has_hardware_rotation=False, + byte_order=BYTE_ORDER_BIG, ) -def test_add_metadata_with_mockobj_id(): - """Test adding metadata with a MockObj ID (converted via str()).""" +def test_add_metadata_with_all_fields(): + """Test adding metadata with all fields set.""" with patch("esphome.components.display.CORE.data", {}): - mock_id = MockObj("my_display_obj") - add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True) - meta = get_display_metadata("my_display_obj") + add_metadata( + ID("my_display"), + 480, + 320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, + ) + meta = get_display_metadata(ID("my_display")) assert meta == DisplayMetaData( - width=480, height=320, has_writer=False, has_hardware_rotation=True + width=480, + height=320, + has_hardware_rotation=True, + byte_order=BYTE_ORDER_LITTLE, ) def test_add_metadata_hardware_rotation_default(): """Test that has_hardware_rotation defaults to False.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 128, 64, False) - meta = get_display_metadata("disp") + add_metadata(ID("disp"), 128, 64) + meta = get_display_metadata(ID("disp")) assert meta.has_hardware_rotation is False + assert meta.byte_order == BYTE_ORDER_BIG -def test_get_display_metadata_missing_returns_none(): - """Test that querying a non-existent ID returns None.""" +def test_add_metadata_with_byte_order(): + """Test adding metadata with explicit byte_order.""" with patch("esphome.components.display.CORE.data", {}): - data = get_display_metadata("no_such_display") - assert data.width == 0 - assert data.height == 0 - assert data.has_writer is False + add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE) + meta = get_display_metadata(ID("disp")) + assert meta.byte_order == BYTE_ORDER_LITTLE + + +def test_get_display_metadata_missing_reads_raw_config(): + """Querying a non-existent ID falls back to raw config lookup.""" + with patch("esphome.components.display.CORE.data", {}): + # Set up a minimal full_config with a display entry so the fallback + # path in get_display_metadata can find the display config. + fc = Config() + fc["display"] = [ + { + "id": ID("no_such_display", True), + "auto_clear_enabled": True, + "dimensions": {"width": 320, "height": 240}, + "byte_order": BYTE_ORDER_LITTLE, + "rotation": 90, + }, + { + "id": ID("other_display", True), + "auto_clear_enabled": "undefined", + "dimensions": (1024, 600), + }, + ] + fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"])) + fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"])) + full_config.set(fc) + data = get_display_metadata(ID("no_such_display")) + assert data.width == 320 + assert data.height == 240 assert data.has_hardware_rotation is False + assert data.has_writer is True + assert data.byte_order == BYTE_ORDER_LITTLE + assert data.rotation == 90 + + data = get_display_metadata(ID("other_display")) + assert data.width == 1024 + assert data.height == 600 + assert data.has_writer is False def test_add_multiple_displays(): """Test adding metadata for multiple displays.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp_a", 320, 240, True) - add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True) + add_metadata(ID("disp_a"), 320, 240) + add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True) all_meta = get_all_display_metadata() assert len(all_meta) == 2 - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True) + assert all_meta["disp_a"] == DisplayMetaData(320, 240, False) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG) -def test_add_metadata_overwrites_existing(): - """Test that adding metadata for the same ID overwrites the previous entry.""" +def test_add_duplicate_id_asserts(): + """Adding metadata for the same ID object twice should assert.""" with patch("esphome.components.display.CORE.data", {}): - add_metadata("disp", 320, 240, True) - add_metadata("disp", 640, 480, False, has_hardware_rotation=True) - meta = get_display_metadata("disp") - assert meta == DisplayMetaData(640, 480, False, True) + id_obj = ID("disp") + add_metadata(id_obj, 320, 240) + with pytest.raises(AssertionError, match="Duplicate"): + add_metadata(id_obj, 640, 480) def test_metadata_is_frozen(): """Test that DisplayMetaData instances are immutable (frozen dataclass).""" - meta = DisplayMetaData(320, 240, True, False) + meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG) with pytest.raises(AttributeError): meta.width = 640 + with pytest.raises(AttributeError): + meta.byte_order = BYTE_ORDER_LITTLE + + +def test_get_all_metadata_asserts_on_unresolved_id(): + """get_all_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_all_display_metadata() + + +def test_get_metadata_asserts_on_unresolved_id(): + """get_display_metadata should assert if any ID has id=None.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata(ID(None), 320, 240) + with pytest.raises(AssertionError, match="resolved"): + get_display_metadata(ID("anything")) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py new file mode 100644 index 0000000000..9a767c0dae --- /dev/null +++ b/tests/component_tests/lvgl/test_validation.py @@ -0,0 +1,177 @@ +"""Tests for LVGL final_validation display metadata checks.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER +from esphome.components.display import add_metadata +from esphome.components.lvgl import final_validation +from esphome.config import Config +from esphome.config_validation import Invalid +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE, ID +from esphome.final_validate import full_config + + +@pytest.fixture(autouse=True) +def _setup_core(): + """Ensure CORE.data has enough context for final_validation.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "host", + KEY_TARGET_FRAMEWORK: "", + } + full_config.set(Config()) + yield + CORE.reset() + + +def _register_displays(*display_ids: str) -> None: + """Register display IDs in full_config so get_path_for_id works.""" + fc = full_config.get() + display_list = [{"id": ID(d, True)} for d in display_ids] + fc["display"] = display_list + for i, disp_id in enumerate(display_ids): + fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"])) + + +def _make_lvgl_config( + display_ids: list[str], + byte_order: str | None = None, +) -> dict: + """Build a minimal LVGL config dict for final_validation.""" + _register_displays(*display_ids) + config = { + "displays": [ID(d, True) for d in display_ids], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + if byte_order is not None: + config[CONF_BYTE_ORDER] = byte_order + return config + + +class TestByteOrderAutoConfig: + """Test that LVGL auto-configures byte_order from display metadata.""" + + def test_inherits_big_endian_from_display(self) -> None: + """LVGL should inherit big_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + def test_inherits_little_endian_from_display(self) -> None: + """LVGL should inherit little_endian from display metadata.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_defaults_to_big_endian_when_no_metadata(self) -> None: + """LVGL should default to big_endian when display has no metadata.""" + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + +class TestByteOrderExplicitMismatchError: + """Test that LVGL rejects explicit byte_order mismatch with display.""" + + def test_raises_on_mismatch(self) -> None: + """Explicit LVGL byte_order different from display should raise.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + with pytest.raises( + Invalid, match="LVGL byte order must match the display byte order" + ): + final_validation(configs) + + def test_no_error_when_matching(self) -> None: + """Explicit LVGL byte_order matching display should pass.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + final_validation(configs) + + +class TestByteOrderMultipleDisplays: + """Test byte_order validation with multiple displays.""" + + def test_consistent_displays_inherit(self) -> None: + """All displays with same byte_order should set LVGL byte_order.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + final_validation(configs) + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE + + def test_inconsistent_displays_raises(self) -> None: + """Displays with different byte_order should raise an error.""" + add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG) + add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["disp_a", "disp_b"])] + with pytest.raises(Invalid, match="same byte_order"): + final_validation(configs) + + +class TestHasWriterCheck: + """Test that LVGL rejects displays with has_writer set.""" + + def test_display_with_writer_raises(self) -> None: + """Display with lambda/pages/auto_clear should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=True) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="not compatible with LVGL"): + final_validation(configs) + + def test_display_without_writer_passes(self) -> None: + """Display without writer should pass.""" + add_metadata(ID("my_disp"), 320, 240, has_writer=False) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestRotationCheck: + """Test that LVGL rejects displays with non-zero rotation.""" + + def test_display_with_rotation_raises(self) -> None: + """Display with rotation should be rejected.""" + add_metadata(ID("my_disp"), 320, 240, rotation=90) + configs = [_make_lvgl_config(["my_disp"])] + with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"): + final_validation(configs) + + def test_display_without_rotation_passes(self) -> None: + """Display with rotation=0 should pass.""" + add_metadata(ID("my_disp"), 320, 240, rotation=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + + +class TestDrawRoundingMerge: + """Test that display draw_rounding is merged into LVGL config.""" + + def test_display_draw_rounding_overrides_lower(self) -> None: + """Display draw_rounding higher than LVGL default should win.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=8) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 8 + + def test_display_draw_rounding_does_not_lower(self) -> None: + """Display draw_rounding lower than LVGL config should not reduce it.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=1) + configs = [_make_lvgl_config(["my_disp"])] + configs[0]["draw_rounding"] = 4 + final_validation(configs) + assert configs[0]["draw_rounding"] == 4 + + def test_zero_draw_rounding_no_change(self) -> None: + """Display with draw_rounding=0 should not affect LVGL config.""" + add_metadata(ID("my_disp"), 320, 240, draw_rounding=0) + configs = [_make_lvgl_config(["my_disp"])] + final_validation(configs) + assert configs[0]["draw_rounding"] == 2 diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index c11c7816e4..e7f5143d91 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,22 +3,15 @@ from collections.abc import Callable from pathlib import Path -from esphome.components.display import ( - DisplayMetaData, - get_all_display_metadata, - get_display_metadata, -) +from esphome.components.const import BYTE_ORDER_BIG +from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( KEY_BOARD, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, ) -from esphome.components.mipi_spi.display import ( - CONFIG_SCHEMA, - FINAL_VALIDATE_SCHEMA, - get_instance, -) +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - config = validated_config({"model": "JC3636W518"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 360 assert meta.height == 360 - # final validation auto-enables show_test_card when no drawing methods are configured - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_single_mode_with_dc_pin( set_core_config: SetCoreConfigCallable, ) -> None: - """A single-mode display with no explicit drawing gets a test card from final validation.""" + """A single-mode display with no explicit drawing gets metadata from schema validation.""" set_core_config( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( - { - "model": "ST7735", - "dc_pin": 18, - } + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"} ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 128 assert meta.height == 160 - assert meta.has_writer is True assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_custom_dimensions( @@ -80,47 +67,22 @@ def test_metadata_custom_dimensions( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config = validated_config( + config = CONFIG_SCHEMA( { "model": "custom", "dc_pin": 18, "dimensions": {"width": 480, "height": 320}, "init_sequence": [[0xA0, 0x01]], + "id": "custom_dimensions", } ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.width == 480 assert meta.height == 320 - # final validation auto-enables show_test_card - assert meta.has_writer is True assert meta.has_hardware_rotation is True -def test_metadata_with_test_card_has_writer( - set_core_config: SetCoreConfigCallable, -) -> None: - """When show_test_card is enabled, has_writer should be True.""" - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - config = validated_config( - { - "model": "custom", - "dc_pin": 18, - "dimensions": {"width": 240, "height": 240}, - "init_sequence": [[0xA0, 0x01]], - "show_test_card": True, - } - ) - get_instance(config) - meta = get_display_metadata(str(config["id"])) - assert meta is not None - assert meta.has_writer is True - - def test_metadata_no_swap_xy_not_full_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: @@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only - config = validated_config({"model": "JC3248W535"}) - get_instance(config) - meta = get_display_metadata(str(config["id"])) + config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) + meta = get_display_metadata(config["id"]) assert meta is not None assert meta.has_hardware_rotation is False @@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, ) - config_a = validated_config( + CONFIG_SCHEMA( { "id": "disp_a", "model": "custom", @@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - config_b = validated_config( + CONFIG_SCHEMA( { "id": "disp_b", "model": "custom", @@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent( "init_sequence": [[0xA0, 0x01]], } ) - get_instance(config_a) - get_instance(config_b) all_meta = get_all_display_metadata() - # final validation auto-enables show_test_card for both - assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True) - assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True) + assert all_meta["disp_a"].width == 320 + assert all_meta["disp_a"].height == 240 + assert all_meta["disp_a"].has_hardware_rotation is True + assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG + assert all_meta["disp_b"].width == 128 + assert all_meta["disp_b"].height == 64 + assert all_meta["disp_b"].has_hardware_rotation is True + assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_native( @@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native( """Full code generation for native.yaml should produce correct metadata.""" generate_main(component_fixture_path("native.yaml")) all_meta = get_all_display_metadata() - # native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation + # native.yaml: model JC3636W518 -> 360x360, full hardware rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=360, height=360, has_writer=True, has_hardware_rotation=True - ) + assert meta.width == 360 + assert meta.height == 360 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG def test_metadata_via_code_generation_lvgl( @@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl( """Full code generation for lvgl.yaml should produce correct metadata.""" generate_main(component_fixture_path("lvgl.yaml")) all_meta = get_all_display_metadata() - # lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation + # lvgl.yaml: model ST7735 -> 128x160, full hw rotation assert len(all_meta) == 1 meta = next(iter(all_meta.values())) - assert meta == DisplayMetaData( - width=128, height=160, has_writer=False, has_hardware_rotation=True - ) + assert meta.width == 128 + assert meta.height == 160 + assert meta.has_hardware_rotation is True + assert meta.byte_order == BYTE_ORDER_BIG From e4980713d1a265613f3006b0ad29439f7a468cc2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:35:23 -0400 Subject: [PATCH 198/282] [core] esphome clean wipes the whole build directory (#16772) --- esphome/__main__.py | 7 +- esphome/build_gen/espidf.py | 6 - esphome/build_gen/platformio.py | 3 +- esphome/espidf/framework.py | 4 +- esphome/writer.py | 104 ++++++++++---- tests/unit_tests/build_gen/test_platformio.py | 32 +---- tests/unit_tests/test_writer.py | 128 ++++++++++-------- 7 files changed, 164 insertions(+), 120 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 47dd8d273c..7c4028da44 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -695,6 +695,11 @@ def _wrap_to_code(name, comp, yaml_util): def write_cpp(config: ConfigType) -> int: from esphome import writer + # Refresh the storage sidecar and clean an incompatible previous build + # before regenerating any sources. This may full-wipe the build dir, so it + # has to run before write_cpp_file writes src/. + writer.update_storage_json() + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -1631,7 +1636,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import writer try: - writer.clean_build() + writer.clean_build(full=True) except OSError as err: _LOGGER.error("Error deleting build files: %s", err) return 1 diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 0b50f72382..9cc7a7ff12 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -7,7 +7,6 @@ from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.writer import update_storage_json def get_available_components() -> list[str] | None: @@ -213,11 +212,6 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC def write_project(minimal: bool = False) -> None: """Write ESP-IDF project files.""" - # Refresh /storage/.yaml.json so the dashboard's - # /info and /downloads endpoints can locate the build (they 404 - # otherwise). This mirrors the PlatformIO build-gen path's call - # in build_gen/platformio.py:write_ini(). - update_storage_json() mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 30dbb69d86..16c1597ccd 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,7 +1,7 @@ from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed -from esphome.writer import find_begin_end, update_storage_json +from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" @@ -58,7 +58,6 @@ def get_ini_content(): def write_ini(content): - update_storage_json() path = CORE.relative_build_path("platformio.ini") if path.is_file(): diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6ef73a2199..2c520d0d2c 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1005,7 +1005,9 @@ def _check_esphome_idf_framework_install( idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) # Logged every invocation (not just on install) so the user can verify the - # override. A changed URL needs ``esphome clean`` to force a re-download. + # override. A changed URL needs ``esphome clean-all`` to force a re-download + # (``esphome clean`` only wipes the build dir, not the extracted framework + # under /idf/frameworks/). if source_url: _LOGGER.info("Using framework source override: %s", source_url) diff --git a/esphome/writer.py b/esphome/writer.py index 84f2f8101a..b29b3c4b79 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -126,6 +126,13 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo def update_storage_json() -> None: + """Refresh the storage sidecar and clean an incompatible build. + + Runs at the start of ``write_cpp`` -- BEFORE any source/project files are + regenerated -- so the clean below can safely ``full``-wipe the whole build + directory (a switch of toolchain/framework/version also drops the stale + project scaffolding, not just the compiled objects). + """ path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -146,7 +153,7 @@ def update_storage_json() -> None: ) else: _LOGGER.info("Core config or version changed, cleaning build files...") - clean_build(clear_pio_cache=False) + clean_build(clear_pio_cache=False, full=True) elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") clean_cmake_cache() @@ -483,48 +490,89 @@ def write_cpp(code_s): def clean_cmake_cache(): - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt" - if pioenvs_cmake_path.is_file(): - _LOGGER.info("Deleting %s", pioenvs_cmake_path) - pioenvs_cmake_path.unlink() + # Drop the CMake cache so a component-set change forces a reconfigure. + # PlatformIO keeps it under .pioenvs//; the native ESP-IDF toolchain + # keeps it under build/ (where espidf's has_outdated_files() treats a + # missing CMakeCache.txt as stale). Only one exists for a given build. + cmake_cache_paths = ( + CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt"), + CORE.relative_build_path("build", "CMakeCache.txt"), + ) + for cmake_cache_path in cmake_cache_paths: + if cmake_cache_path.is_file(): + _LOGGER.info("Deleting %s", cmake_cache_path) + cmake_cache_path.unlink() -def clean_build(clear_pio_cache: bool = True): +def clean_build(clear_pio_cache: bool = True, *, full: bool = False): + """Remove build artifacts. + + By default only the compiled outputs are removed (``.pioenvs`` / + ``.piolibdeps`` / the native ESP-IDF ``build`` and ``managed_components`` + dirs) while the generated ``src/`` and project files are kept. This is what + in-build callers need: they regenerate a source/sdkconfig and then force a + rebuild without discarding the sources they just wrote. + + ``full=True`` wipes the entire build directory instead. Used by the + ``esphome clean`` command and by the pre-build clean in + ``update_storage_json`` (which runs before sources are regenerated) -- in + both cases nothing is mid-regeneration, so the next compile rebuilds from + scratch. It also drops stale project scaffolding the allow-list keeps (e.g. a + leftover platformio.ini / CMakeLists.txt from the other toolchain), making a + toolchain switch reliable. + """ # Allow skipping cache cleaning for integration tests if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): _LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)") return - pioenvs = CORE.relative_pioenvs_path() - if pioenvs.is_dir(): - _LOGGER.info("Deleting %s", pioenvs) - rmtree(pioenvs) - piolibdeps = CORE.relative_piolibdeps_path() - if piolibdeps.is_dir(): - _LOGGER.info("Deleting %s", piolibdeps) - rmtree(piolibdeps) - dependencies_lock = CORE.relative_build_path("dependencies.lock") - if dependencies_lock.is_file(): - _LOGGER.info("Deleting %s", dependencies_lock) - dependencies_lock.unlink() + if full: + if CORE.build_path is not None: + build_path = Path(CORE.build_path) + if build_path.is_dir(): + _LOGGER.info("Deleting %s", build_path) + rmtree(build_path) + else: + pioenvs = CORE.relative_pioenvs_path() + if pioenvs.is_dir(): + _LOGGER.info("Deleting %s", pioenvs) + rmtree(pioenvs) + piolibdeps = CORE.relative_piolibdeps_path() + if piolibdeps.is_dir(): + _LOGGER.info("Deleting %s", piolibdeps) + rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if dependencies_lock.is_file(): + _LOGGER.info("Deleting %s", dependencies_lock) + dependencies_lock.unlink() + # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir + # and the Component Manager's fetched managed components live under + # the project's build path, not under .pioenvs / .piolibdeps. + for name in ("build", "managed_components"): + idf_path = CORE.relative_build_path(name) + if idf_path.is_dir(): + _LOGGER.info("Deleting %s", idf_path) + rmtree(idf_path) + + # The idedata cache is derived from the build but lives under the data dir, + # not the build path, so it must be removed separately in both modes. idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if idedata_cache.is_file(): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() - # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir - # and the Component Manager's fetched managed components live under - # the project's build path, not under .pioenvs / .piolibdeps. - for name in ("build", "managed_components"): - idf_path = CORE.relative_build_path(name) - if idf_path.is_dir(): - _LOGGER.info("Deleting %s", idf_path) - rmtree(idf_path) if not clear_pio_cache: return + # The native ESP-IDF toolchain caches PlatformIO libraries converted to IDF + # components under /pio_components, shared across builds and keyed + # by source hash (the analog of PlatformIO's global package cache). Drop it + # on an explicit clean so a corrupt/stale converted lib is re-fetched. + pio_components = CORE.relative_internal_path("pio_components") + if pio_components.is_dir(): + _LOGGER.info("Deleting %s", pio_components) + rmtree(pio_components) + # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index a124dbc128..da0010afa3 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -12,13 +12,6 @@ from esphome.build_gen import platformio from esphome.core import CORE -@pytest.fixture -def mock_update_storage_json() -> Generator[MagicMock]: - """Mock update_storage_json for all tests.""" - with patch("esphome.build_gen.platformio.update_storage_json") as mock: - yield mock - - @pytest.fixture def mock_write_file_if_changed() -> Generator[MagicMock]: """Mock write_file_if_changed for tests.""" @@ -26,9 +19,7 @@ def mock_write_file_if_changed() -> Generator[MagicMock]: yield mock -def test_write_ini_creates_new_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_creates_new_file(tmp_path: Path) -> None: """Test write_ini creates a new platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -50,9 +41,7 @@ framework = arduino assert platformio.INI_AUTO_GENERATE_END in file_content -def test_write_ini_updates_existing_file( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_updates_existing_file(tmp_path: Path) -> None: """Test write_ini updates existing platformio.ini file.""" CORE.build_path = str(tmp_path) @@ -97,9 +86,7 @@ framework = arduino assert "platform = old" not in file_content -def test_write_ini_preserves_custom_sections( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: +def test_write_ini_preserves_custom_sections(tmp_path: Path) -> None: """Test write_ini preserves custom sections outside auto-generate markers.""" CORE.build_path = str(tmp_path) @@ -148,7 +135,6 @@ monitor_speed = 115200 def test_write_ini_no_change_when_content_same( tmp_path: Path, - mock_update_storage_json: MagicMock, mock_write_file_if_changed: MagicMock, ) -> None: """Test write_ini doesn't rewrite file when content is unchanged.""" @@ -174,15 +160,3 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] - - -def test_write_ini_calls_update_storage_json( - tmp_path: Path, mock_update_storage_json: MagicMock -) -> None: - """Test write_ini calls update_storage_json.""" - CORE.build_path = str(tmp_path) - - content = "[env:test]\nplatform = esp32" - - platformio.write_ini(content) - mock_update_storage_json.assert_called_once() diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6f137fb351..1487517ca2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -341,8 +341,8 @@ def test_update_storage_json_logging_when_old_is_none( with caplog.at_level("INFO"): update_storage_json() - # Verify clean_build was called - mock_clean_build.assert_called_once() + # Verify clean_build was called with a full wipe (runs before src is written) + mock_clean_build.assert_called_once_with(clear_pio_cache=False, full=True) # Verify the correct log message was used (not the component removal message) assert "Core config or version changed, cleaning build files..." in caplog.text @@ -392,60 +392,50 @@ def test_update_storage_json_logging_components_removed( new_storage.save.assert_called_once_with("/test/path") +def _mock_cmake_cache_paths(mock_core: MagicMock, tmp_path: Path) -> None: + """Wire relative_pioenvs_path/relative_build_path to tmp_path subtrees.""" + mock_core.name = "test_device" + mock_core.relative_pioenvs_path.side_effect = (tmp_path / ".pioenvs").joinpath + mock_core.relative_build_path.side_effect = tmp_path.joinpath + + @patch("esphome.writer.CORE") -def test_clean_cmake_cache( +def test_clean_cmake_cache_platformio( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache removes CMakeCache.txt file.""" - # Create directory structure - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache removes the PlatformIO CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / ".pioenvs" / "test_device" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file exists before - assert cmake_cache_file.exists() - - # Call the function with caplog.at_level("INFO"): clean_cmake_cache() - # Verify file was removed assert not cmake_cache_file.exists() - - # Verify logging assert "Deleting" in caplog.text assert "CMakeCache.txt" in caplog.text @patch("esphome.writer.CORE") -def test_clean_cmake_cache_no_pioenvs_dir( +def test_clean_cmake_cache_esp_idf( mock_core: MagicMock, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_cmake_cache when pioenvs directory doesn't exist.""" - # Setup non-existent directory path - pioenvs_dir = tmp_path / ".pioenvs" + """Test clean_cmake_cache removes the native ESP-IDF build/CMakeCache.txt.""" + _mock_cmake_cache_paths(mock_core, tmp_path) + cmake_cache_file = tmp_path / "build" / "CMakeCache.txt" + cmake_cache_file.parent.mkdir(parents=True) + cmake_cache_file.write_text("# CMake cache file") - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir + with caplog.at_level("INFO"): + clean_cmake_cache() - # Verify directory doesn't exist - assert not pioenvs_dir.exists() - - # Call the function - should not crash - clean_cmake_cache() - - # Verify directory still doesn't exist - assert not pioenvs_dir.exists() + assert not cmake_cache_file.exists() + assert str(cmake_cache_file) in caplog.text @patch("esphome.writer.CORE") @@ -453,27 +443,11 @@ def test_clean_cmake_cache_no_cmake_file( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" - # Create directory structure without CMakeCache.txt - pioenvs_dir = tmp_path / ".pioenvs" - pioenvs_dir.mkdir() - device_dir = pioenvs_dir / "test_device" - device_dir.mkdir() - cmake_cache_file = device_dir / "CMakeCache.txt" + """Test clean_cmake_cache when no CMakeCache.txt exists -- should not crash.""" + _mock_cmake_cache_paths(mock_core, tmp_path) - # Setup mocks - mock_core.relative_pioenvs_path.return_value = pioenvs_dir - mock_core.name = "test_device" - - # Verify file doesn't exist - assert not cmake_cache_file.exists() - - # Call the function - should not crash clean_cmake_cache() - # Verify file still doesn't exist - assert not cmake_cache_file.exists() - @patch("esphome.writer.CORE") def test_clean_build( @@ -507,6 +481,11 @@ def test_clean_build( managed_components_dir.mkdir() (managed_components_dir / "espressif__arduino-esp32").mkdir() + # Converted-PIO-library cache (native ESP-IDF), under the data dir. + pio_components_dir = tmp_path / "pio_components" + pio_components_dir.mkdir() + (pio_components_dir / "abc12345").mkdir() + # Create PlatformIO cache directory platformio_cache_dir = tmp_path / ".platformio" / ".cache" platformio_cache_dir.mkdir(parents=True) @@ -529,6 +508,7 @@ def test_clean_build( assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() + assert pio_components_dir.exists() assert platformio_cache_dir.exists() # Mock PlatformIO's ProjectConfig cache_dir @@ -554,6 +534,7 @@ def test_clean_build( assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() + assert not pio_components_dir.exists() assert not platformio_cache_dir.exists() # Verify logging @@ -567,6 +548,41 @@ def test_clean_build( assert "PlatformIO cache" in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_full_wipes_build_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """full=True wipes the whole build dir (incl. src/) but keeps siblings.""" + build_dir = tmp_path / "build" / "test" + (build_dir / "src").mkdir(parents=True) + (build_dir / "src" / "main.cpp").write_text("// generated") + (build_dir / "platformio.ini").write_text("[platformio]") + (build_dir / ".pioenvs").mkdir() + + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + + # A sibling of the build dir (under the data dir) must survive. + survivor = tmp_path / "keep_me.txt" + survivor.write_text("keep") + + # build_path may be a str (e.g. set from config); clean_build must coerce. + mock_core.build_path = str(build_dir) + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + with caplog.at_level("INFO"): + clean_build(clear_pio_cache=False, full=True) + + assert not build_dir.exists() + assert not idedata_cache.exists() + assert survivor.exists() + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_build_partial_exists( mock_core: MagicMock, @@ -586,6 +602,7 @@ def test_clean_build_partial_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -623,6 +640,7 @@ def test_clean_build_nothing_exists( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify nothing exists assert not pioenvs_dir.exists() @@ -659,6 +677,7 @@ def test_clean_build_platformio_not_available( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() @@ -697,6 +716,7 @@ def test_clean_build_empty_cache_dir( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -1425,6 +1445,7 @@ def test_clean_build_handles_readonly_files( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify file is read-only assert not os.access(readonly_file, os.W_OK) @@ -1489,6 +1510,7 @@ def test_clean_build_reraises_for_other_errors( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath try: # Mock os.access in writer module to return True (writable) From 89ddd34cb9cd7e81e38d9dc2b305e6e5bc887b0d Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:55:51 +0200 Subject: [PATCH 199/282] [dmsr] [breaking] Fix decryption that uses custom auth key. Add CRC to telegram sensor. Automatic hex string detection in equipment_id fields. Support EON Hungary smart meters (#16561) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- esphome/components/dsmr/dsmr.cpp | 7 ++++--- esphome/components/dsmr/dsmr.h | 11 ++++++++--- esphome/components/dsmr/sensor.py | 8 ++++---- esphome/components/dsmr/text_sensor.py | 1 + platformio.ini | 2 +- 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 52d75d1601..29c8b414f6 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f +a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 31ec1ce5b5..05f9a78156 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.4.0") + cg.add_library("esphome/dsmr_parser", "1.8.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index 2fa51f73af..9580464a2e 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() { bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); - ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.full_content().size()), + telegram.full_content().data()); MyData data; if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { @@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size()); } return true; } diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index e55db9f976..3642309c26 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -74,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice { receive_timeout_(receive_timeout), request_pin_(request_pin), buffer_(max_telegram_length), - packet_accumulator_(buffer_, crc_check) { + packet_accumulator_(buffer_, crc_check), + dlms_decryptor_(gcm_decryptor_, crc_check) { this->set_decryption_key_(decryption_key); } @@ -97,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice { // Remove before 2026.8.0 ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } + void set_decryption_key(const std::string &decryption_key) { + // Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols, + // otherwise `Aes128GcmDecryptionKey::from_hex` will fail. + this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str()); + } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -143,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice { std::vector buffer_; dsmr_parser::PacketAccumulator packet_accumulator_; Aes128GcmDecryptorImpl gcm_decryptor_; - dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_; std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 292e5a1156..7d93ee62e1 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -248,10 +248,6 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional("electricity_switch_position"): sensor.sensor_schema( - accuracy_decimals=3, - state_class=STATE_CLASS_MEASUREMENT, - ), cv.Optional("electricity_failures"): sensor.sensor_schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -808,6 +804,10 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("electricity_switch_position"): cv.invalid( + "'electricity_switch_position' has moved to the 'text_sensor' platform." + "Move it under 'text_sensor' to fix." + ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index a8f29c7ca8..54b5711923 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -14,6 +14,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version"): text_sensor.text_sensor_schema(), cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), diff --git a/platformio.ini b/platformio.ini index 8a89f96b39..4ac60d8099 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.4.0 ; dsmr + esphome/dsmr_parser@1.8.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 2009f6cc5f80bef33913383b8786cdbc133c58c1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:43:48 +1000 Subject: [PATCH 200/282] [lvgl] Fix indicator updates (#16780) --- esphome/components/lvgl/widgets/__init__.py | 1 + esphome/components/lvgl/widgets/meter.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 400f7c709b..4d62c3de05 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -290,6 +290,7 @@ class Widget: # Properties for linear equations self.slope = None self.y_int = None + self.parent = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index e2407fad5a..166e88f382 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -430,7 +430,8 @@ class MeterType(WidgetType): tvar, LV_PART.MAIN, await arc_style.get_var() ) lw = Widget.create(iid, tvar, arc_indicator_type) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_TICK_STYLE: # No object created for this @@ -482,7 +483,8 @@ class MeterType(WidgetType): if option in v: props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_IMAGE: add_lv_use(CONF_IMAGE) @@ -501,7 +503,8 @@ class MeterType(WidgetType): } iw = await widget_to_code(props, image_indicator_type, scale_var) await iw.set_property(CONF_SRC, await lv_image.process(src)) - await set_indicator_values(iw, v) + iw.parent = scale_var + await set_indicator_values(scale_var, iw, v) # Hide the scale line lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) @@ -607,27 +610,27 @@ async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) async def set_value(w: Widget): - await set_indicator_values(w, config) + await set_indicator_values(w.parent, w, config) return await action_to_code( widget, set_value, action_id, template_arg, args, config ) -async def set_indicator_values(indicator: Widget, config): +async def set_indicator_values(scale: MockObj, indicator: Widget, config): """Update scale section values (replaces meter indicator values)""" start_value = await get_start_value(config) end_value = await get_end_value(config) if indicator.type is arc_indicator_type: # For scale sections, we update the range if start_value is not None and end_value is not None: - lv.scale_section_set_range(indicator.obj, start_value, end_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, end_value) elif start_value is not None: # If only start value, use it as both start and end (single point) - lv.scale_section_set_range(indicator.obj, start_value, start_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, start_value) elif end_value is not None: # If only end value, assume range from 0 to end_value - lv.scale_section_set_range(indicator.obj, 0, end_value) + lv.scale_set_section_range(scale, indicator.obj, 0, end_value) return if start_value is None: From 712ef2ec0eba901ecf2fecec48df36f27f3796b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:45:52 -0400 Subject: [PATCH 201/282] Bump esptool from 5.2.0 to 5.3.0 (#16774) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 17b618dde7..85d9857e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.2.0 +esptool==5.3.0 click==8.3.3 esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 From eba70dc193d61982a94920b80de64f0d76f5d777 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:07 -0400 Subject: [PATCH 202/282] Bump github/codeql-action from 4.36.0 to 4.36.1 (#16775) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dfc0e08bfa..122bb30b5e 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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{matrix.language}}" From 87735d71a043293e3cdd09224d6bcbfa7e2ee3c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:46:22 -0400 Subject: [PATCH 203/282] Bump actions/checkout from 6.0.2 to 6.0.3 (#16776) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 40 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 13 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 6c80d36d20..e48d6f69bd 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 675bbe9d2c..2a5b701248 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index d9148fb06d..73c437467b 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 89fbec5420..2a40675f3b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -42,7 +42,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 6713fcc454..43d530128c 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 025b960985..35cfce65f8 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63efff1b3a..d3fc19ca41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -123,7 +123,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -151,11 +151,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: esphome/device-builder ref: main @@ -221,7 +221,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -281,7 +281,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -353,7 +353,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -405,7 +405,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -434,7 +434,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -490,7 +490,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -575,7 +575,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -670,7 +670,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -764,7 +764,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -889,7 +889,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python @@ -971,7 +971,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -997,7 +997,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.base_ref }} @@ -1179,7 +1179,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1248,7 +1248,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 013517bde6..1bd60fd11d 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 7cdbfcf328..5ad0b02de1 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 122bb30b5e..c71d7204de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index e8320672d2..0e2efb1bcf 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 344bd416c6..8efc395951 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84be3c8e22..8796ddf7f0 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout Home Assistant - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: home-assistant/core path: lib/home-assistant From 3b0f669f4782117661a9603137615f1fae99d128 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 3 Jun 2026 14:38:00 -0300 Subject: [PATCH 204/282] [gree] Fix HEAT_COOL advertised when supports_heat is false; restrict YAN swing to vertical (#16199) --- esphome/components/gree/gree.cpp | 16 ++++++++++++++++ esphome/components/gree/gree.h | 1 + 2 files changed, 17 insertions(+) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 705c741dd0..a794e7721f 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,7 +5,23 @@ 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. + this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL); + this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH); + } if (model == GREE_YX1FF) { this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 24453750ae..1eb812ae46 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -94,6 +94,7 @@ class GreeClimate : 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_(); From 7b8cbe2de19d4d530af6103fda140bbb4575142f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:18:16 +1000 Subject: [PATCH 205/282] [sdl] Add option to choose display screen (#16363) --- esphome/components/sdl/display.py | 36 ++++++++++++++++++++++++---- esphome/components/sdl/sdl_esphome.h | 6 ++--- tests/components/sdl/common.yaml | 22 +++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 78c180aa65..57266f33e2 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -20,6 +20,7 @@ Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags") +CONF_CENTERED_ON_DISPLAY = "centered_on_display" CONF_SDL_OPTIONS = "sdl_options" CONF_SDL_ID = "sdl_id" CONF_WINDOW_OPTIONS = "window_options" @@ -31,6 +32,8 @@ WINDOW_OPTIONS = ( "resizable", ) +SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 + def get_sdl_options(value): if value != "": @@ -47,6 +50,20 @@ def get_window_options(): return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} +def _validate_position(config: dict) -> dict: + if CONF_CENTERED_ON_DISPLAY in config: + if CONF_X in config or CONF_Y in config: + raise cv.Invalid( + f"Cannot specify '{CONF_CENTERED_ON_DISPLAY}' with '{CONF_X}' and '{CONF_Y}' options" + ) + return config + if CONF_X in config and CONF_Y in config: + return config + if CONF_X in config or CONF_Y in config: + raise cv.Invalid(f"Must specify both '{CONF_X}' and '{CONF_Y}' options") + raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'") + + CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( cv.Schema( @@ -66,10 +83,13 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_POSITION): cv.Schema( { - cv.Required(CONF_X): cv.int_, - cv.Required(CONF_Y): cv.int_, + cv.Optional(CONF_X): cv.int_, + cv.Optional(CONF_Y): cv.int_, + cv.Optional(CONF_CENTERED_ON_DISPLAY): cv.int_range( + 0, 128 + ), } - ), + ).add_extra(_validate_position), **get_window_options(), } ), @@ -105,7 +125,15 @@ async def to_code(config): cg.add(var.set_window_options(create_flags)) if position := window_options.get(CONF_POSITION): - cg.add(var.set_position(position[CONF_X], position[CONF_Y])) + if (centered := position.get(CONF_CENTERED_ON_DISPLAY)) is not None: + cg.add( + var.set_position( + SDL_WINDOWPOS_CENTERED_MASK | centered, + SDL_WINDOWPOS_CENTERED_MASK | centered, + ) + ) + else: + cg.add(var.set_position(position[CONF_X], position[CONF_Y])) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 3f54b70560..a5ebf44c38 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -28,7 +28,7 @@ class Sdl : public display::Display { this->height_ = height; } void set_window_options(uint32_t window_options) { this->window_options_ = window_options; } - void set_position(uint16_t pos_x, uint16_t pos_y) { + void set_position(int32_t pos_x, int32_t pos_y) { this->pos_x_ = pos_x; this->pos_y_ = pos_y; } @@ -54,8 +54,8 @@ class Sdl : public display::Display { int width_{}; int height_{}; uint32_t window_options_{0}; - int pos_x_{SDL_WINDOWPOS_UNDEFINED}; - int pos_y_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED}; + int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED}; SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 66f93915b6..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -10,6 +10,28 @@ display: dimensions: width: 450 height: 600 + window_options: + position: + x: 100 + y: 100 + + - platform: sdl + id: second_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 1 + + - platform: sdl + id: third_display + dimensions: + width: 450 + height: 600 + window_options: + position: + centered_on_display: 0 binary_sensor: - platform: sdl From 92819d86586037b369fadf67ff1ce2ddcd14d723 Mon Sep 17 00:00:00 2001 From: Jon Little Date: Wed, 3 Jun 2026 15:54:34 -0500 Subject: [PATCH 206/282] [logger] Fix USB JTAG VFS symbols linked when logging is disabled (#15721) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/logger/__init__.py | 6 +++++- esphome/components/logger/logger_esp32.cpp | 6 ++++-- esphome/core/defines.h | 5 +++-- .../logger/common-uart0_no_logging.yaml | 3 +++ .../test-uart0_no_logging.esp32-h2-idf.yaml | 1 + .../build_components_base.esp32-h2-idf.yaml | 20 +++++++++++++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tests/components/logger/common-uart0_no_logging.yaml create mode 100644 tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-h2-idf.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index e4921ae196..9629dce0bf 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -461,7 +461,11 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") # USB Serial JTAG code is compiled when platform supports it. # Enable secondary USB serial JTAG console so the VFS functions are available. - if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG: + if ( + CORE.is_esp32 + and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG + and has_serial_logging + ): require_usb_serial_jtag_secondary() require_vfs_termios() except cv.Invalid: diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 8e0c00267a..b216a5427d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -6,7 +6,7 @@ #include -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) #include @@ -29,7 +29,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG static void init_usb_serial_jtag_() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin @@ -108,7 +108,9 @@ void Logger::pre_setup() { #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG init_usb_serial_jtag_(); +#endif break; #endif } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 765c1aa3b2..6c840f56ee 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -358,11 +358,12 @@ #define USE_LOGGER_USB_SERIAL_JTAG #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) + defined(USE_ESP32_VARIANT_ESP32H21) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32S31) #define USE_LOGGER_USB_CDC #define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG +#define USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #endif #endif diff --git a/tests/components/logger/common-uart0_no_logging.yaml b/tests/components/logger/common-uart0_no_logging.yaml new file mode 100644 index 0000000000..3bb1691767 --- /dev/null +++ b/tests/components/logger/common-uart0_no_logging.yaml @@ -0,0 +1,3 @@ +logger: + hardware_uart: UART0 + baud_rate: 0 diff --git a/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml new file mode 100644 index 0000000000..76444a2e89 --- /dev/null +++ b/tests/components/logger/test-uart0_no_logging.esp32-h2-idf.yaml @@ -0,0 +1 @@ +<<: !include common-uart0_no_logging.yaml diff --git a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml new file mode 100644 index 0000000000..a60c1fddd9 --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml @@ -0,0 +1,20 @@ +esphome: + name: componenttestesp32h2idf + friendly_name: $component_name + +esp32: + board: esp32-h2-devkitm-1 + framework: + type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 78d8a93fff36c749a9786811e01c382e79d5971f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:56 -0400 Subject: [PATCH 207/282] [remote_base] Fix RC5 decoding at either receive polarity (#16767) --- .../components/remote_base/rc5_protocol.cpp | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index c7f79ad84a..fd136a4e6d 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5"; static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; +static constexpr uint8_t NHALFBITS = NBITS * 2; void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { static bool toggle = false; @@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) { } toggle = !toggle; } + optional RC5Protocol::decode(RemoteReceiveData src) { - RC5Data out{ - .address = 0, - .command = 0, - }; - uint8_t field_bit; - - if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) { - field_bit = 1; - } else if (src.expect_space(2 * BIT_TIME_US)) { - field_bit = 0; - } else { - return {}; - } - - if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) || - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) && - (((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) || - ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) { - return {}; - } - - uint32_t out_data = 0; - for (int bit = NBITS - 4; bit >= 1; bit--) { - if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && - (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { - out_data |= 0 << bit; - } else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) && - (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { - out_data |= 1 << bit; + // Expand the runs into half-bit levels (true = mark). Each run is exactly one + // half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else. + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped -- + // S1 is 1, so its first half sits at the idle level (at either polarity) and + // merges into the pre-frame idle. Captured half-bits start at index 1. + bool halfbits[NHALFBITS + 2]; + uint8_t n = 1; + for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(BIT_TIME_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(BIT_TIME_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * BIT_TIME_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * BIT_TIME_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; } else { - return {}; + break; } } - if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) { - out_data |= 0; - } else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) { - out_data |= 1; + + // Expect a full frame once the leading half is restored: 27 captured halves + // (n == 28) or 26 when the final bit also ends on idle and its trailing half + // is dropped too (n == 27). A dropped edge half is the inverse of its partner + // (a Manchester bit always transitions mid-bit), so reconstruct the leading + // half (always) and the trailing half (only when it was dropped). + if (n != NHALFBITS && n != NHALFBITS - 1) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; } - out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u; - out.address = (out_data >> 6) & 0x1F; - return out; + const bool carrier = halfbits[1]; + uint16_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const bool first = halfbits[2 * i]; + const bool second = halfbits[2 * i + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit + return RC5Data{ + .address = static_cast((bits >> 6) & 0x1F), + .command = static_cast((bits & 0x3F) | (field_bit ? 0 : 0x40)), + }; } + void RC5Protocol::dump(const RC5Data &data) { ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command); } From 74a1ff9fc76b4ee5129e47c7ac4a007fd7471987 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:01 -0400 Subject: [PATCH 208/282] [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) --- esphome/storage_json.py | 29 ++++++++++-- esphome/writer.py | 13 ++++-- tests/unit_tests/test_espidf_toolchain.py | 9 ++++ tests/unit_tests/test_storage_json.py | 56 ++++++++++++++++++++++- tests/unit_tests/test_writer.py | 27 +++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index ba576fcfd7..3bdda1a9a1 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import CoreType @@ -101,6 +101,7 @@ class StorageJSON: core_platform: str | None = None, toolchain: str | None = None, area: str | None = None, + framework_version: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -141,6 +142,8 @@ class StorageJSON: self.toolchain = toolchain # The area of the node self.area = area + # The framework version the build used (for esp32, the resolved ESP-IDF version) + self.framework_version = framework_version def as_dict(self): return { @@ -162,6 +165,7 @@ class StorageJSON: "core_platform": self.core_platform, "toolchain": self.toolchain, "area": self.area, + "framework_version": self.framework_version, } def to_json(self): @@ -173,10 +177,12 @@ class StorageJSON: @staticmethod def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() + framework_version: str | None = None if esph.is_esp32: from esphome.components import esp32 hardware = esp32.get_esp32_variant(esph) + framework_version = str(esp32.idf_version()) return StorageJSON( storage_version=1, name=esph.name, @@ -200,6 +206,7 @@ class StorageJSON: core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, area=esph.area, + framework_version=framework_version, ) @staticmethod @@ -249,6 +256,7 @@ class StorageJSON: core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") area = storage.get("area") + framework_version = storage.get("framework_version") return StorageJSON( storage_version, name, @@ -268,6 +276,7 @@ class StorageJSON: core_platform, toolchain, area, + framework_version, ) @staticmethod @@ -311,10 +320,24 @@ class StorageJSON: # esp32.get_esp32_variant(). target_platform on disk is the variant # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION from esphome.const import KEY_VARIANT - CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform} + esp32_data = {KEY_VARIANT: self.target_platform} + if self.framework_version: + import esphome.config_validation as cv + + try: + esp32_data[KEY_IDF_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + CORE.data[KEY_ESP32] = esp32_data def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/writer.py b/esphome/writer.py index b29b3c4b79..a9c072f156 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: ``src_version`` differs, ``build_path`` differs, the build ``toolchain`` differs (e.g. switching between the PlatformIO and native ESP-IDF toolchains, which produce incompatible build trees), - or a previously loaded integration was removed in *new*. Adding - integrations or changing unrelated fields (friendly name, esphome - version, etc.) does not trigger a clean. + the ``framework`` or ``framework_version`` differs (e.g. switching + arduino <-> esp-idf, or bumping the ESP-IDF version, which also + produce incompatible build trees), or a previously loaded + integration was removed in *new*. Adding integrations or changing + unrelated fields (friendly name, esphome version, etc.) does not + trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.toolchain != new.toolchain: return True + if old.framework != new.framework: + return True + if old.framework_version != new.framework_version: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index d00d8662f5..8849ea8bc8 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -148,3 +148,12 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: mock_transform.assert_called_once() assert result == {"cxx_path": "regen"} + + +def test_get_core_framework_version_from_core_data(): + """The version is read from CORE.data when validation populated it.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + import esphome.config_validation as cv + + CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} + assert toolchain._get_core_framework_version() == "5.5.4" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 105d78505f..7ba56b05f4 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import storage_json +from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE @@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None: framework="arduino", core_platform="esp32", area="Living Room", + framework_version="5.3.1", ) result = storage.as_dict() @@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None: assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" + assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: @@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" - with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + with ( + patch("esphome.components.esp32.get_esp32_variant") as mock_variant, + patch("esphome.components.esp32.idf_version") as mock_idf_version, + ): mock_variant.return_value = "ESP32-C3" + mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) @@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" + assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain( assert CORE.toolchain is None +def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[esp32][idf_version].""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + storage = _make_storage_with_toolchain("esp-idf") + storage.framework_version = "5.3.1" + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "5.3.1" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "5.3.1" + + loaded.apply_to_core() + assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) + + +def test_storage_json_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate idf_version.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION + + loaded = _make_storage_with_toolchain("esp-idf") + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] + + +def test_storage_json_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_storage_with_toolchain("esp-idf") + loaded.framework_version = "not-a-version" + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 1487517ca2..c8cf68ff3e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -112,6 +112,7 @@ def create_storage() -> Callable[..., StorageJSON]: framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), toolchain=kwargs.get("toolchain", "platformio"), + framework_version=kwargs.get("framework_version"), ) return _create @@ -157,6 +158,32 @@ def test_storage_should_clean_when_toolchain_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_framework_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework changes. + + Switching between arduino and esp-idf produces incompatible build trees + even on the same toolchain, so the build must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino") + new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_framework_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the framework version changes. + + A different framework/ESP-IDF version compiles against a different SDK, so + the stale build tree must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1") + new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: From 0fcfd1e3d636e9a1810f715832494c09ac82fa94 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:08 -0400 Subject: [PATCH 209/282] [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) --- esphome/components/rp2040/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 6ec0ee08b8..f98cde7968 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -501,18 +501,21 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment, FileSystemLoader + from jinja2 import Environment lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) if not lwip_defines: return - template_dir = Path(__file__).parent - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - keep_trailing_newline=True, + # Read the template via pathlib and render from a string rather than using + # FileSystemLoader. jinja2's loader joins the search path with posixpath, which + # breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are + # not accepted, causing a spurious TemplateNotFound (see issue #16732). + template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" ) - template = jinja_env.get_template("lwipopts.h.jinja") + jinja_env = Environment(keep_trailing_newline=True) + template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) lwip_dir = CORE.relative_build_path("lwip_override") From 0d7d091e7127b42edd7542c3e5c9dc894ed67bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:46:17 -0400 Subject: [PATCH 210/282] [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) --- .../components/esp32_ble_server/__init__.py | 28 +++++++++-- .../esp32_ble_server/__init__.py | 0 .../esp32_ble_server/test_esp32_ble_server.py | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/__init__.py create mode 100644 tests/component_tests/esp32_ble_server/test_esp32_ble_server.py diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16) From 93f25258ee65f741fa4654047232ba38db1b5041 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:08:36 +1200 Subject: [PATCH 211/282] [config] Add --no-defaults flag to config command (#16718) --- esphome/__main__.py | 17 +++++- esphome/config.py | 15 +++++ tests/unit_tests/test_main.py | 82 ++++++++++++++++++++++++++ tests/unit_tests/test_substitutions.py | 41 +++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7c4028da44..f7d3f8e834 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1428,7 +1428,16 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util - if not CORE.verbose: + if getattr(args, "no_defaults", False): + user_config = getattr(config, "user_config", None) + if user_config is None: + _LOGGER.warning( + "--no-defaults requested but the user-only config snapshot is " + "unavailable; falling back to the validated configuration." + ) + else: + config = user_config + elif not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) if not args.show_secrets: @@ -2152,6 +2161,12 @@ def parse_args(argv): parser_config.add_argument( "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config.add_argument( + "--no-defaults", + help="Only output the user-supplied configuration without " + "schema defaults applied.", + action="store_true", + ) parser_config_hash = subparsers.add_parser( "config-hash", help="Calculate the hash of the configuration." diff --git a/esphome/config.py b/esphome/config.py index 9da39a387b..91e6df8bad 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc from contextlib import contextmanager import contextvars +import copy import functools import heapq import logging @@ -168,6 +169,11 @@ class Config(OrderedDict, fv.FinalValidateConfig): self.output_paths: list[tuple[ConfigPath, str]] = [] # A list of components ids with the config path self.declare_ids: list[tuple[core.ID, ConfigPath]] = [] + # Snapshot of the user's configuration after substitutions/packages/ + # extend-remove resolution but before any schema validation defaults + # are applied. Populated by validate_config; used by `esphome config + # --no-defaults` to emit only the user-supplied keys. + self.user_config: ConfigType | None = None self._data = {} # Store pending validation tasks (in heap order) self._validation_tasks: list[_ValidationStepTask] = [] @@ -1076,6 +1082,15 @@ def validate_config( ) return result + # Snapshot the user's config before any schema validation defaults are + # applied. preload_core_config and later validation steps rewrite entries + # in-place with defaulted values; deep-copying here preserves the + # user-supplied keys for `esphome config --no-defaults`. + result.user_config = copy.deepcopy(config) + if substitutions is not None: + result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) + result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) + # 2. Load partial core config import esphome.core.config as core_config diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8cce60d351..e99a630e83 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -471,6 +471,88 @@ def test_command_config__show_secrets_skips_redaction( assert "\\033[8m" not in output +def test_command_config__no_defaults_dumps_user_snapshot( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``--no-defaults`` dumps ``config.user_config`` instead of the + validated config, so schema defaults don't leak into the output.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["esphome"] = {"name": "test", "build_path": "build/test"} + validated["wifi"] = {"ssid": "MyNet", "reboot_timeout": "15min"} + validated.user_config = { + "esphome": {"name": "test"}, + "wifi": {"ssid": "MyNet"}, + } + + result = command_config(args, validated) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + # Defaults present on the validated config must not appear. + assert "reboot_timeout" not in output + assert "build_path" not in output + + +def test_command_config__no_defaults_warns_when_snapshot_missing( + tmp_path: Path, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """If the snapshot is unavailable (e.g. a plain dict was passed in), + ``--no-defaults`` logs a warning and falls back to the input config.""" + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = command_config(args, {"wifi": {"ssid": "MyNet"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + assert any( + "user-only config snapshot is unavailable" in rec.message + for rec in caplog.records + ) + + +def test_command_config__no_defaults_skips_strip_default_ids( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """When ``--no-defaults`` is set, ``strip_default_ids`` isn't run -- + the user snapshot is already free of schema-injected IDs.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["sensor"] = [{"name": "x", "id": "auto_generated"}] + validated.user_config = {"sensor": [{"name": "x"}]} + + with patch( + "esphome.__main__.strip_default_ids", side_effect=AssertionError + ) as mock_strip: + result = command_config(args, validated) + + assert result == 0 + mock_strip.assert_not_called() + output = capfd.readouterr().out + assert "name: x" in output + assert "auto_generated" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b5816f742e..baaa99f2a7 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -361,6 +361,47 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di assert result[CONF_SUBSTITUTIONS]["var2"] == "value2" +def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: + """validate_config stores a deep copy of the user's config -- with + substitutions re-added and no schema defaults applied -- on + ``result.user_config`` for ``esphome config --no-defaults``. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + # Snapshot is populated. + assert result.user_config is not None + # Substitutions are re-added and appear first. + assert list(result.user_config.keys())[0] == CONF_SUBSTITUTIONS + assert result.user_config[CONF_SUBSTITUTIONS]["var1"] == "value1" + # User-supplied keys are present without schema-default fields like + # ``build_path`` (which preload_core_config injects on the validated + # result's esphome section). + assert result.user_config["esphome"] == {"name": "test_device"} + assert "build_path" not in result.user_config["esphome"] + assert "min_version" not in result.user_config["esphome"] + assert result.user_config["esp32"] == {"board": "esp32dev"} + + +def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> None: + """The snapshot is independent of subsequent mutations to the result + config -- preload_core_config rewrites ``esphome:`` in place, but the + snapshot keeps the user's literal block. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is not None + # preload_core_config injected build_path onto the validated config. + assert "build_path" in result["esphome"] + # The snapshot was taken before that and is unaffected. + assert "build_path" not in result.user_config["esphome"] + # And the two are not aliased. + assert result["esphome"] is not result.user_config["esphome"] + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type. From a02b9c379641a3c093df709b0fbae0b6dc02d0fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:45 -0500 Subject: [PATCH 212/282] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#16791) 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 2a5b701248..c6e9a358ab 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3fc19ca41..ca1fb07fda 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -170,7 +170,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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -368,7 +368,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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8796ddf7f0..ab1ce2b587 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 ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d47f6b896e9edbdbcd46f70ff6016daffcdfbc09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:06:59 -0500 Subject: [PATCH 213/282] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 in /.github/actions/restore-python (#16790) 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 03b4803860..66d016b42d 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 3e562b9267b142c1f01502397234b4e9fb1ac23e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 22:30:36 -0500 Subject: [PATCH 214/282] [ci] Fix memory impact build selecting unbuildable platform (#16788) --- script/determine-jobs.py | 104 +++++++++++++++++----------- script/helpers.py | 25 +++++++ script/test_build_components.py | 16 +---- tests/script/test_determine_jobs.py | 100 +++++++++++++++++++++----- 4 files changed, 171 insertions(+), 74 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index cf098f92c9..94a78e8423 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -70,6 +70,7 @@ from helpers import ( get_changed_components, get_component_from_path, get_component_test_files, + get_component_test_platforms, get_components_with_dependencies, get_cpp_changed_components, get_fixture_to_test_files, @@ -77,7 +78,6 @@ from helpers import ( get_target_branch, git_ls_files, is_validate_only_file, - parse_test_filename, root_path, ) from split_components_for_ci import create_intelligent_batches @@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical -# Platform-specific components that can only be built on their respective platforms -# These components contain platform-specific code and cannot be cross-compiled -# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here -PLATFORM_SPECIFIC_COMPONENTS = frozenset( - { - "esp32", # ESP32 platform implementation - "esp8266", # ESP8266 platform implementation - "rp2040", # Raspberry Pi Pico / RP2040 platform implementation - "libretiny", # LibreTiny base platform implementation - "bk72xx", # Beken BK72xx platform implementation (uses LibreTiny) - "rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny) - "ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny) - "host", # Host platform (for testing on development machine) - "nrf52", # Nordic nRF52 platform implementation (uses Zephyr) - "zephyr", # Zephyr RTOS platform implementation - } -) - # Platform preference order for memory impact analysis # This order is used when no platform-specific hints are detected from filenames # Priority rationale: @@ -1006,23 +988,24 @@ def detect_memory_impact_config( ] = {} # Track which platforms each component supports for component in sorted(changed_component_set): - # Look for test files on preferred platforms - test_files = get_component_test_files(component, all_variants=True) - if not test_files: - continue - - # Check if component has tests for any preferred platform - available_platforms = [ - platform - for test_file in test_files - if (platform := parse_test_filename(test_file)[1]) != "all" - and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE - ] + # Discover the platforms this component has BASE tests for, using the + # same logic as the build runner (get_component_test_platforms wraps the + # shared get_component_test_files + parse_test_filename helpers). Base + # tests only: the memory impact CI build runs test_build_components.py + # with --base-only, which compiles base test..yaml files but + # never variant test-..yaml files. Counting + # variant-only platforms here would let us select a platform the build + # then has nothing to compile for, producing no memory output. + available_platforms = { + Platform(platform) + for platform in get_component_test_platforms(component) + if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE + } if not available_platforms: continue - component_platforms_map[component] = set(available_platforms) + component_platforms_map[component] = available_platforms components_with_tests.append(component) # If no components have tests, don't run memory impact @@ -1084,20 +1067,57 @@ def detect_memory_impact_config( ) platform = _select_platform_by_count(platform_counts) - # Filter out platform-specific components that are incompatible with selected platform - # Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform - # Other components (wifi, logger, etc.) are cross-platform and can build anywhere - compatible_components = [ - component - for component in components_with_tests - if component not in PLATFORM_SPECIFIC_COMPONENTS - or platform in component_platforms_map.get(component, set()) - ] + # Keep only components that have a base test on the selected platform. + # The merged build runs test_build_components.py -t --base-only, + # so a component without a base test..yaml compiles nothing and + # contributes no memory output. This also covers platform-specific + # components (esp32, esp8266, etc.), which only have tests on their own + # platform. When components don't share a common platform we build the + # largest subset that does, dropping the rest. + def components_supporting(target: Platform) -> list[str]: + return [ + component + for component in components_with_tests + if target in component_platforms_map.get(component, set()) + ] - # If no components are compatible with the selected platform, don't run + compatible_components = components_supporting(platform) + + # A platform hint (or no-common-platform fallback) can pick a platform that + # no changed component actually has a base test for, leaving nothing to + # build. In that case fall back to the platform supported by the most + # components. component_platforms_map is non-empty (guarded above) and every + # value is a non-empty platform set (components with no supported platform + # are skipped at discovery), so this always yields a buildable platform with + # at least one compatible component. + if not compatible_components: + platform = _select_platform_by_count( + Counter( + p for platforms in component_platforms_map.values() for p in platforms + ) + ) + compatible_components = components_supporting(platform) + + # Defensive backstop: unreachable given the invariant above, but guards + # against a future regression in platform selection silently passing an + # empty component list to the build. if not compatible_components: return {"should_run": "false"} + # Log components dropped because they lack a base test on the selected + # platform so partial-subset builds are visible in CI logs. + dropped_components = [ + component + for component in components_with_tests + if component not in compatible_components + ] + if dropped_components: + print( + f"Memory impact: Dropping components without a base test on " + f"{platform}: {dropped_components}", + file=sys.stderr, + ) + # Debug output print("Memory impact analysis:", file=sys.stderr) print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 9839e766e2..1ebfe405a7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -149,6 +149,31 @@ def get_component_test_files( return files +def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]: + """Return the set of platforms a component has compilable test files for. + + Uses the same discovery as ``test_build_components.py`` (``get_component_test_files`` + + ``parse_test_filename``) so callers agree with what the build runner would + actually compile. With ``base_only=True`` (the default, matching the + memory-impact build's ``--base-only``), only base ``test..yaml`` + files are considered; variant ``test-..yaml`` files are + excluded. The ``"all"`` platform sentinel is excluded. + + Args: + component: Component name (e.g. "wifi") + base_only: If True, only consider base test files (default). + + Returns: + Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}). + """ + platforms: set[str] = set() + for test_file in get_component_test_files(component, all_variants=not base_only): + platform = parse_test_filename(test_file)[1] + if platform != "all": + platforms.add(platform) + return platforms + + def is_validate_only_file(test_file: Path) -> bool: """Return True if the given path is a config-only validate file. diff --git a/script/test_build_components.py b/script/test_build_components.py index 767b55c94b..651268609e 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -42,6 +42,7 @@ from script.analyze_component_buses import ( from script.helpers import ( get_component_test_files, is_validate_only_file, + parse_test_filename, split_conflicting_groups, ) from script.merge_component_configs import merge_component_configs @@ -122,21 +123,6 @@ def find_component_tests( return dict(component_tests) -def parse_test_filename(test_file: Path) -> tuple[str, str]: - """Parse test filename to extract test name and platform. - - Args: - test_file: Path to test file - - Returns: - Tuple of (test_name, platform) - """ - parts = test_file.stem.split(".") - if len(parts) == 2: - return parts[0], parts[1] # test, platform - return parts[0], "all" - - def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: """Get all platform base files. diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index ac3c6424bf..acc268fa68 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> @pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: - """Test memory impact detection when components have no common platform.""" + """Test memory impact detection when components have no common platform. + + The merged build runs with --base-only on a single platform, so components + without a base test on the selected platform cannot be built and must be + dropped. We build the largest subset that shares the selected platform + rather than handing the runner components it has nothing to compile for + (which previously produced "0 passed, 0 failed" and a failed memory + extraction). + """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should pick the most frequently supported platform + # No common platform: pick the most preferred platform among those supported + # (esp8266-ard outranks esp32-idf in the preference list) and build only the + # components that have a base test on it. wifi (esp32-idf only) is dropped. assert result["should_run"] == "true" - assert set(result["components"]) == {"wifi", "logger"} - # When no common platform, picks most commonly supported - # esp8266-ard is preferred over esp32-idf in the preference list - assert result["platform"] in ["esp32-idf", "esp8266-ard"] + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["logger"] + assert result["use_merged_config"] == "true" + + +def test_detect_memory_impact_config_variant_only_platform_excluded( + tmp_path: Path, +) -> None: + """Regression test for the const + shelly_dimmer memory-impact failure. + + Reproduces https://github.com/esphome/esphome/actions/runs/26746938473 + where a platform hint selected esp32-idf even though neither changed + component had a base test.esp32-idf.yaml. The merged --base-only build then + found nothing to compile ("0 passed, 0 failed") and memory extraction + failed. Also covers a component whose only esp32-idf test is a *variant* + (test-*.esp32-idf.yaml): --base-only never compiles variants, so it must + not count toward platform availability. + """ + tests_dir = tmp_path / "tests" / "components" + + # const: base test only on esp32-s3-idf + const_dir = tests_dir / "const" + const_dir.mkdir(parents=True) + (const_dir / "test.esp32-s3-idf.yaml").write_text("test: const") + + # shelly_dimmer: base test only on esp8266-ard + shelly_dir = tests_dir / "shelly_dimmer" + shelly_dir.mkdir(parents=True) + (shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer") + + # mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml). + # --base-only would never build it, so it must be excluded entirely. + mdns_dir = tests_dir / "mdns" + mdns_dir.mkdir(parents=True) + (mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns") + + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object(helpers, "root_path", str(tmp_path)), + patch.object(determine_jobs, "changed_files") as mock_changed_files, + ): + # The "_esp32" filename yields an esp32-idf platform hint, reproducing + # the original bug where the hint picked a platform no component could + # build as a base test. + mock_changed_files.return_value = [ + "esphome/components/const/const.cpp", + "esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp", + "esphome/components/mdns/mdns.cpp", + ] + + result = determine_jobs.detect_memory_impact_config() + + # The esp32-idf hint is unbuildable (no base test), so we fall back to the + # platform supported by the most components, broken by preference order: + # esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the + # component with a base test on the selected platform is returned; the + # variant-only mdns is excluded entirely. + assert result["should_run"] == "true" + assert result["platform"] == "esp8266-ard" + assert result["components"] == ["shelly_dimmer"] assert result["use_merged_config"] == "true" @@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components( @pytest.mark.usefixtures("mock_target_branch_dev") -def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: - """Test memory impact detection for components with only variant test files. +def test_detect_memory_impact_config_variant_only_components_skipped( + tmp_path: Path, +) -> None: + """Components with only variant tests are skipped for memory impact. - This verifies that memory impact analysis works correctly for components like - improv_serial, ethernet, mdns, etc. which only have variant test files - (test-*.yaml) instead of base test files (test.*.yaml). + Components like improv_serial and ethernet only have variant test files + (test-*.yaml), no base test..yaml. The memory-impact build runs + test_build_components.py with --base-only, which never compiles variants, so + these components have nothing buildable and must not be selected. Selecting + them previously produced "0 passed, 0 failed" and a failed memory extraction. """ # Create test directory structure tests_dir = tmp_path / "tests" / "components" @@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: result = determine_jobs.detect_memory_impact_config() - # Should detect both components even though they only have variant tests - assert result["should_run"] == "true" - assert set(result["components"]) == {"improv_serial", "ethernet"} - # Both components support esp32-idf - assert result["platform"] == "esp32-idf" - assert result["use_merged_config"] == "true" + # Neither component has a base test, so nothing is buildable under --base-only + assert result["should_run"] == "false" # Tests for clang-tidy split mode logic From 53d685f2423284ad3f5d5c219de72e8d2ff385b8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:37:29 -0400 Subject: [PATCH 215/282] [mixer] Give mixer test its own speaker id to avoid CI grouping collision (#16792) --- tests/components/mixer/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index ef613b82bc..dee42ed280 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -13,13 +13,13 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: mixer_output_speaker_id dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit channel: stereo - platform: mixer - output_speaker: speaker_id + output_speaker: mixer_output_speaker_id bits_per_sample: 32 num_channels: 2 source_speakers: From ffaa31febc7f7bb5b02f2ed7df4cdfbca2e00e65 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:48:34 -0400 Subject: [PATCH 216/282] [clang-tidy] Hash idf_component.yml and trigger hash hook on more inputs (#16753) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .clang-tidy.hash | 2 +- .pre-commit-config.yaml | 2 +- script/clang_tidy_hash.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 29c8b414f6..648b31f8f0 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d +44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a076128975..3b6278e6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,7 +63,7 @@ repos: name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ + files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ pass_filenames: false additional_dependencies: [] - id: ci-custom diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index f478535567..1a6e4eb7be 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -105,6 +105,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: sdkconfig_content = read_file_bytes(sdkconfig_path) hasher.update(sdkconfig_content) + # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF + # build's include set, which clang-tidy analyzes. + idf_component_path = repo_root / "esphome" / "idf_component.yml" + if idf_component_path.exists(): + hasher.update(read_file_bytes(idf_component_path)) + return hasher.hexdigest() From 891ec33c94ef89d48b2dedea25f8dd6cf3d4d660 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:39:17 -0400 Subject: [PATCH 217/282] [esp32] Deduplicate PlatformIO library conversion by resolving the batch together (#16756) --- esphome/components/esp32/__init__.py | 31 +- esphome/espidf/component.py | 566 ++++++++++--------- esphome/espidf/extra_script.py | 4 +- tests/unit_tests/test_espidf_component.py | 626 ++++++++++++++-------- 4 files changed, 702 insertions(+), 525 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d2dc979966..160c06534e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -46,10 +46,10 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, EsphomeError, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_component +from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType @@ -2598,13 +2598,6 @@ def _write_sdkconfig(): clean_build(clear_pio_cache=False) -def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: - dependency: dict[str, str] = {} - name, _version, path = generate_idf_component(library) - dependency["override_path"] = str(path) - return name, dependency - - def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} @@ -2678,13 +2671,21 @@ def _write_idf_component_yml(): ) if CORE.using_toolchain_esp_idf: - # Try to convert PlatformIO library to ESP-IDF components - for name, library in CORE.platformio_libraries.items(): + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() # Don't process arduino libraries - if name in ARDUINO_DISABLED_LIBRARIES: - continue - dependency_name, dependency = _platformio_library_to_dependency(library) - dependencies[dependency_name] = dependency + if name not in ARDUINO_DISABLED_LIBRARIES + ] + for component in generate_idf_components(libraries): + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 050002d9e2..7398a91c36 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,4 +1,6 @@ +from collections import deque from collections.abc import Callable +from dataclasses import dataclass, field import glob import hashlib import itertools @@ -8,7 +10,7 @@ import os from pathlib import Path import re import tempfile -from typing import TypeVar +from typing import Any, TypeVar from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git, yaml_util @@ -154,72 +156,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _get_package_from_pio_registry( - username: str | None, pkgname: str, requirements: str -) -> tuple[str, str, str | None, str | None]: - """ - Fetch package information from PlatformIO registry. - - This function queries the PlatformIO registry to find a library package - that matches the given criteria and returns its metadata including version - and download URL. - - Args: - username: The owner/username of the package (can be None) - pkgname: The name of the package - requirements: Version requirements (e.g., "^1.0.0") - - Returns: - tuple[str, str, str | None, str | None]: - A tuple containing (owner, name, version, download_url) - where version and download_url can be None if not found - """ - - from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.package.meta import PackageSpec - - # Create a minimal PackageManagerRegistry class - class PackageManagerRegistry(PackageManagerRegistryMixin): - def __init__(self): - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value, custom_system=None): - return True - - pio_registry = PackageManagerRegistry() - - # Fetch package metadata from registry - package = pio_registry.fetch_registry_package( - PackageSpec( - owner=username, - name=pkgname, - ) - ) - owner = package["owner"]["username"] - name = package["name"] - - # Find the best matching version based on requirements - version = pio_registry.pick_best_registry_version( - package.get("versions"), - PackageSpec(owner=username, name=pkgname, requirements=requirements), - ) - - # If no version found, return with None for version and URL - if not version: - return owner, name, None, None - - # Find the compatible package file for this version - pkgfile = pio_registry.pick_compatible_pkg_file(version["files"]) - - # If no package file found, return with None for URL but valid version - if not pkgfile: - return owner, name, version["name"], None - - return owner, name, version["name"], pkgfile["download_url"] - - def _apply_extra_script(component: IDFComponent) -> None: """Run a PIO ``extraScript`` and fold its captured env vars into ``component.data["build"]["flags"]`` so the existing -L/-l/-D @@ -339,77 +275,6 @@ def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[s return [r for r in selected if Path(r).is_file()] -def _convert_library_to_component(library: Library) -> IDFComponent: - """ - Convert a Library object to an IDFComponent object by resolving its metadata. - - This function handles the conversion of library specifications to component - objects, resolving versions through PlatformIO registry when needed or - parsing direct repository URLs. - - Args: - library: The Library object containing name, version, and/or repository information - - Returns: - IDFComponent: The resolved component with name, version, and URL - - Raises: - RuntimeError: If no artifact can be found for the library - """ - name = None - version = None - source = None - - # Repository is provided directly - if library.repository: - # Parse repository URL: path becomes the component name, fragment - # (if any) becomes the git ref stored on GitSource. A missing - # fragment is fine -- clone_or_update leaves the depth-1 clone on - # the remote's default branch, matching PIO's lib_deps behavior - # and external_components handling. - split_result = urlsplit(library.repository) - - # Sanitize name - name = str(split_result.path).strip("/") - name = name.removesuffix(".git") - - # IDF Component Manager only accepts "*", a 40-char commit hash, or - # semver here. The actual git ref is preserved in GitSource.ref; - # override_path makes this field cosmetic at build time. - version = "*" - repository = urlunsplit(split_result._replace(fragment="")) - - ref = split_result.fragment.strip() or None - source = GitSource(str(repository), ref) - - # Version is provided - resolve using PlatformIO registry - elif library.version: - name = library.name - if "/" not in name: - owner, pkgname = None, name - else: - owner, pkgname = name.split("/", 1) - - owner, pkgname, version, url = _get_package_from_pio_registry( - owner, pkgname, library.version - ) - if url is None: - raise RuntimeError( - f"Can't find an pkg file from PlatformIO registry for library {library}" - ) - - name = _owner_pkgname_to_name(owner, pkgname) - source = URLSource(url) - - if source is None: - raise RuntimeError(f"Can't find an artifact associated to library {library}") - - assert name, "Missing library name" - assert version, "Missing library version" - - return IDFComponent(name, version, source) - - def _split_list_by_condition( items: list[str], match_fn: Callable[[str], str | None] ) -> tuple[list[str], list[str]]: @@ -599,8 +464,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Every dependency goes through _generate_idf_component → - # component.download() before this runs, so .path is always set. + # Every dependency has been resolved and downloaded before this runs, + # so .path is always set. data["dependencies"][dependency.get_sanitized_name()] = { "override_path": str(dependency.path), } @@ -657,81 +522,6 @@ def _check_library_data(data: dict): ) -def _process_dependencies(component: IDFComponent): - """ - Process library dependencies and generate ESP-IDF components. - - Args: - component: IDFComponent object being processed - - Returns: - None - """ - - name, version = component.name, component.version - dependencies = component.data.get("dependencies") - if not dependencies: - return - - # PIO's library.json accepts both the list-of-dicts form and the - # shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize - # the dict form so the loop below sees a uniform list. Iterating a - # dict gives string keys, which would silently fail the - # ``"name" in dependency`` substring check and skip every entry. - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - dependencies = normalized - - _LOGGER.info("Processing %s@%s component dependencies...", name, version) - for dependency in dependencies: - # Validate dependency structure - if not all(k in dependency for k in ("name", "version")): - _LOGGER.debug("Ignore invalid library: %s", dependency) - continue - - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug( - "Skip %s@%s: %s", dependency["name"], dependency["version"], str(e) - ) - continue - - # The version field may actually contain a URL - version = dependency["version"] - url = None - try: - result = urlparse(version) - if all([result.scheme, result.netloc]): - url, version = version, None - except (TypeError, ValueError): - pass - - # Generate ESP-IDF component from PlatformIO library - component.dependencies.append( - _generate_idf_component( - Library( - _owner_pkgname_to_name( - dependency.get("owner", None), dependency.get("name") - ), - version, - url, - ) - ) - ) - - def _parse_library_json(library_json_path: PathType): """ Load and parse a JSON file describing a library. @@ -772,92 +562,294 @@ def _parse_library_properties(library_properties_path: PathType): return data -def _generate_idf_component(library: Library, force: bool = False) -> IDFComponent: +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- ESP-IDF/target compatibility is + handled elsewhere, not by the PlatformIO registry. """ - Generate an ESP-IDF component from a library specification. + from platformio.package.manager._registry import PackageManagerRegistryMixin - This function resolves the library, downloads it, processes metadata files, - and generates necessary ESP-IDF build files (CMakeLists.txt, idf_component.yml). + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True - Returns: - IDFComponent: The generated component object with resolved metadata + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. """ - _LOGGER.info("Generate IDF component for %s library ...", library) + from platformio.package.meta import PackageSpec - # Resolve component name, version and url - component = _convert_library_to_component(library) - name, version = component.name, component.version + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] - # Download the library - component.download(force) - - # Paths to component metadata and build files - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - cmakelists_txt_path = component.path / "CMakeLists.txt" - idf_component_yml_path = component.path / "idf_component.yml" - - # Bundled CMakeLists.txt / idf_component.yml are ignored -- library - # authors' IDF support is frequently broken (bogus REQUIRES, hard-coded - # arduino-esp32, etc.). We always regenerate. - - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: raise RuntimeError( - "Invalid PIO library: missing library.json and/or library.properties" + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" ) - # Check if the component is usable with ESP-IDF before executing any - # third-party Python from the library (``_apply_extra_script`` below). - _check_library_data(component.data) - - # If the library declares a PIO ``extraScript``, run it against a - # fake SCons env so we can fold its captured LIBPATH/LIBS/etc into - # the build-flag pipeline ``generate_cmakelists_txt`` consumes - # below. Without this, libraries that wire per-MCU archive linking - # via extraScript fail to link under native ESP-IDF. - _apply_extra_script(component) - - # Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed) - _process_dependencies(component) - - _LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version) - write_file_if_changed( - cmakelists_txt_path, - generate_cmakelists_txt(component), - ) - - _LOGGER.debug("Generating idf_component.yml for %s@%s ...", name, version) - write_file_if_changed( - idf_component_yml_path, - generate_idf_component_yml(component), - ) - - return component + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] -def generate_idf_component( - library: Library, force: bool = False -) -> tuple[str, str, Path]: +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. """ - Generate an ESP-IDF component and return its name, version, and path. + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] - This is a wrapper function that calls _generate_idf_component and returns - the standardized tuple format (name, version, path). - Args: - library: The library specification containing name, version, and repository URL - force: If True, forces re-download of the library even if it exists locally +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" - Returns: - tuple[str, str, Path]: A tuple containing (component_name, component_version, component_path) + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``generate_idf_components`` warns + about that after resolution rather than merging the nodes. """ - component = _generate_idf_component(library, force) - return component.get_sanitized_name(), component.version, component.path + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: + """Resolve and convert a batch of PlatformIO libraries to IDF components. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. + """ + nodes: dict[str, _LibNode] = {} + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, IDFComponent] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = IDFComponent(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = IDFComponent( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download() + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + _check_library_data(component.data) + except InvalidIDFComponent as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with ESP-IDF: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + _check_library_data(dependency) + except InvalidIDFComponent as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec( + _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), + dep_version, + dep_url, + ) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # regenerate build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) + + return [components[key] for key in top_level if key in components] diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 5f59254aee..4d06fb842a 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -6,8 +6,8 @@ section instead of static fields. The script runs under SCons during PIO's build and mutates the active ``Environment`` (``env.Append``, ``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU. -ESPHome's PIO→IDF converter (``_generate_idf_component``) doesn't run -SCons, so these scripts were previously ignored and any library +ESPHome's PIO→IDF converter doesn't run SCons, so these scripts were +previously ignored and any library relying on them failed to link under ``toolchain: esp-idf``. This module provides a small shim that ``exec``s an extra-script with a fake ``env`` object, captures the common ``env.Append(...)`` calls, diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 4f0a71053d..602ff03942 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -21,13 +21,15 @@ from esphome.espidf.component import ( URLSource, _check_library_data, _collect_filtered_files, - _convert_library_to_component, + _node_key, + _normalize_dependencies, _parse_library_json, _parse_library_properties, - _process_dependencies, + _resolve_registry_version, _split_list_by_condition, generate_cmakelists_txt, generate_idf_component_yml, + generate_idf_components, ) @@ -162,43 +164,6 @@ def test_generate_cmakelists_txt_references_project_managed_components_variable( assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content -def test_generate_idf_component_overwrites_bundled_files( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - esp32_idf_core: None, -) -> None: - # A library that ships its own CMakeLists.txt + idf_component.yml must - # have both replaced by ESPHome's generated content. Library authors' - # bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded - # frameworks), so we always regenerate from library.json. - from esphome.espidf.component import _generate_idf_component - - (tmp_path / "src").mkdir() - (tmp_path / "src" / "main.cpp").write_text("// dummy\n") - (tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"})) - (tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n") - (tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n") - - fake_component = IDFComponent( - "owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy") - ) - fake_component.path = tmp_path - monkeypatch.setattr( - esphome.espidf.component, - "_convert_library_to_component", - lambda _lib: fake_component, - ) - monkeypatch.setattr(fake_component, "download", lambda force=False: None) - - _generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None)) - - cml = (tmp_path / "CMakeLists.txt").read_text() - manifest = (tmp_path / "idf_component.yml").read_text() - assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml - assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest - assert "idf_component_register" in cml - - def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) @@ -419,200 +384,58 @@ empty= assert "empty" not in result -def test_convert_library_with_repository(): - lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "v1.2.3" - - -def test_convert_library_with_branch_ref(): - lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref == "some-branch" - - -def test_convert_library_missing_ref_uses_default_branch(): - """A bare URL with no #ref clones the remote's default branch. - - Matches PIO's lib_deps behavior and external_components handling -- - git.clone_or_update with ref=None leaves the depth-1 clone on - whatever branch the remote HEAD points at. - """ - lib = Library("name", None, "https://github.com/foo/bar.git") - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "*" - assert isinstance(result.source, GitSource) - assert result.source.ref is None - - -def test_convert_library_registry(monkeypatch): - lib = Library("foo/bar", "^1.0.0", None) - - monkeypatch.setattr( - esphome.espidf.component, - "_get_package_from_pio_registry", - lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"), +def test_node_key_git_with_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#v1.2.3" ) - - result = _convert_library_to_component(lib) - - assert result.name == "foo/bar" - assert result.version == "1.2.3" - assert isinstance(result.source, URLSource) + assert key == "foo/bar" + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", "v1.2.3") -def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch): - tmp_component.data = { - "dependencies": [ - { - "name": "foo", - "version": "1.0", - } - ] - } - - monkeypatch.setattr( - esphome.espidf.component, - "_generate_idf_component", - lambda lib: esphome.espidf.component.IDFComponent( - lib.name, lib.version, source=URLSource("http://dummy.com") - ), +def test_node_key_git_branch_ref(): + key, is_git, locator = _node_key( + "name", None, "https://github.com/foo/bar.git#some-branch" ) - - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 1 + assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch") -def test_process_dependencies_skips_invalid(tmp_component): - tmp_component.data = { - "dependencies": [ - {"name": "foo", "version": "1.0", "platforms": ["arduino"]}, - {"invalid": "entry"}, - ] - } - - _process_dependencies(tmp_component) - - assert tmp_component.dependencies == [] +def test_node_key_git_no_ref(): + _key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git") + assert is_git is True + assert locator == ("https://github.com/foo/bar.git", None) -def test_process_dependencies_dict_form(tmp_component, monkeypatch): - """PIO library.json shorthand ``{"owner/Name": "version"}`` is honored. +def test_node_key_registry_owner_name(): + key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) + assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) - Iterating a dict gives string keys, which would silently fail the - ``"name" in dependency`` substring check. Normalize to list-of-dicts - first so the dict form (used by e.g. tesla-ble for its nanopb dep) - is treated the same as the verbose list form. - """ - captured: list[Library] = [] - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) +def test_node_key_registry_bare_name(): + key, is_git, locator = _node_key("bar", "1.0", None) + assert (key, is_git, locator) == ("bar", False, (None, "bar")) - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": "^0.4.91", - "BareName": "1.2.3", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate + +def test_normalize_dependencies_none(): + assert _normalize_dependencies(None) == [] + + +def test_normalize_dependencies_list_form(): + deps = [{"name": "foo", "version": "1.0"}] + assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}] + + +def test_normalize_dependencies_dict_form(): + out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"}) + assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out + assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out + + +def test_normalize_dependencies_dict_form_nested_spec(): + out = _normalize_dependencies( + {"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}} ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(tmp_component.dependencies) == 2 - names = sorted(lib.name for lib in captured) - versions = sorted(lib.version for lib in captured) - assert names == ["BareName", "nanopb/Nanopb"] - assert versions == ["1.2.3", "^0.4.91"] - - -def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch): - """A dict-value that's a URL gets routed to ``repository`` like the list form.""" - captured: list[Library] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent(library.name, "*", source=URLSource("http://dummy.com")) - - tmp_component.data = { - "dependencies": { - "foo/Bar": "https://github.com/foo/bar.git#main", - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "foo/Bar" - assert captured[0].version is None - assert captured[0].repository == "https://github.com/foo/bar.git#main" - - -def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch): - """A dict-value that's itself a dict is merged into the entry. - - PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}`` - for entries that need fields beyond just a version (platforms, - frameworks, etc.). The extra fields flow into _check_library_data - via the entry merge. - """ - captured: list[Library] = [] - checked: list[dict] = [] - - def fake_generate(library): - captured.append(library) - return IDFComponent( - library.name, library.version, source=URLSource("http://dummy.com") - ) - - tmp_component.data = { - "dependencies": { - "nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}, - } - } - monkeypatch.setattr( - esphome.espidf.component, "_generate_idf_component", fake_generate - ) - monkeypatch.setattr( - esphome.espidf.component, - "_check_library_data", - checked.append, - ) - - _process_dependencies(tmp_component) - - assert len(captured) == 1 - assert captured[0].name == "nanopb/Nanopb" - assert captured[0].version == "^0.4.91" - # Extra spec fields reach _check_library_data so platform/framework - # gating still applies. - assert checked == [ + assert out == [ { "name": "Nanopb", "owner": "nanopb", @@ -620,3 +443,364 @@ def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypa "platforms": "espidf", } ] + + +def _patch_registry(monkeypatch, versions): + """Patch the registry client to serve a canned version list (no network). + + Only ``fetch_registry_package`` is faked; the real + ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on + the canned data so the intersection logic is exercised for real. + """ + registry = esphome.espidf.component._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [ + {"name": v, "files": [{"download_url": f"http://x/{v}.tar.gz"}]} + for v in versions + ], + }, + ) + monkeypatch.setattr( + esphome.espidf.component, "_make_registry_client", lambda: registry + ) + + +def test_resolve_registry_version_intersects_constraints(monkeypatch): + _patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"]) + owner, name, version, url = _resolve_registry_version( + "esphome", "libsodium", {"==1.10021.0", "^1.10018.1"} + ) + assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0") + assert url == "http://x/1.10021.0.tar.gz" + + +def test_resolve_registry_version_picks_highest_satisfying(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"]) + _owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"}) + assert version == "1.5.0" + + +def test_resolve_registry_version_conflict_raises(monkeypatch): + _patch_registry(monkeypatch, ["1.0.0", "2.0.0"]) + with pytest.raises(RuntimeError, match="satisfies all requirements"): + _resolve_registry_version("o", "p", {"==1.0.0", "==2.0.0"}) + + +def test_generate_idf_components_dedupes_shared_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A and B both depend on shared C under different version specs. The batch + # must resolve C once with BOTH requirements collected, wire a single C + # instance into both, and regenerate (overwrite) each library's build files. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "^1.10018.1"} + ], + }, + "esphome/C": {"name": "C"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + (self.path / "CMakeLists.txt").write_text("# TRIPWIRE\n") + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + captured: dict[str, set[str]] = {} + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + captured[f"{owner}/{pkgname}"] = set(requirements) + version = "1.10021.0" if pkgname == "C" else "1.0.0" + return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # C resolved once (not once per consumer) with BOTH requirements gathered. + assert captured["esphome/C"] == {"==1.10021.0", "^1.10018.1"} + assert resolve_calls.count("C") == 1 + # Top-level components returned in request order. + assert [c.name for c in top] == ["esphome/A", "esphome/B"] + # A and B reference the SAME single C instance (deduped). + a_dep = top[0].dependencies[0] + b_dep = top[1].dependencies[0] + assert a_dep.name == "esphome/C" + assert a_dep is b_dep + # The bundled CMakeLists was overwritten with generated content. + generated = (a_dep.path / "CMakeLists.txt").read_text() + assert "TRIPWIRE" not in generated + assert "idf_component_register" in generated + + +def test_generate_idf_components_handles_dependency_cycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A -> B -> A. Must terminate (not recurse forever) and wire the cycle with + # a single instance per component. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "esphome", "name": "A", "version": "1.0.0"}], + }, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + component_a = top[0] + component_b = component_a.dependencies[0] + assert component_b.name == "esphome/B" + # The cycle is wired back to the same A instance, not a duplicate. + assert component_b.dependencies[0] is component_a + + +def test_generate_idf_components_git_overrides_registry_warns( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A pulls shared as a registry pin; B pulls the same component from a git + # source. The git source wins, but the dropped registry pin must be warned + # about (not silently discarded). + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "shared", "version": "==1.0.0"} + ], + }, + "esphome/B": { + "name": "B", + "dependencies": [ + { + "owner": "esphome", + "name": "shared", + "version": "https://github.com/esphome/shared.git#main", + } + ], + }, + "esphome/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + # shared resolved from the git source (version "*"), not the registry pin. + shared = top[0].dependencies[0] + assert shared.name == "esphome/shared" + assert isinstance(shared.source, GitSource) + assert "using the git source" in caplog.text + assert "==1.0.0" in caplog.text + + +def test_generate_idf_components_missing_manifest_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A library with neither library.json nor library.properties is invalid; + # fail loudly rather than silently generating build files for it. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + # no library.json / library.properties written + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="missing library.json"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_warns_on_noncanonical_duplicate( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, + caplog: pytest.LogCaptureFixture, +) -> None: + # A references "shared" (bare) and B references "owner/shared"; both resolve + # to the same canonical name but as distinct graph nodes, so they aren't + # deduplicated -- warn about it. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "shared", "version": "1.0.0"}], + }, + "esphome/B": { + "name": "B", + "dependencies": [{"owner": "owner", "name": "shared", "version": "1.0.0"}], + }, + "owner/shared": {"name": "shared"}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner or "owner", + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert "referenced under multiple names" in caplog.text + + +def test_generate_idf_components_incompatible_top_level_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, + # not be silently dropped. + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text( + json.dumps({"name": "A", "platforms": ["espressif8266"]}) + ) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + +def test_generate_idf_components_incompatible_dependency_skipped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # An incompatible *transitive* dependency is skipped (not fatal): A is fine, + # its esp8266-only dep B is dropped and not wired. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}], + }, + "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, + } + + def fake_download(self, force=False): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + monkeypatch.setattr( + esphome.espidf.component, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + top = generate_idf_components([Library("esphome/A", "1.0.0", None)]) + + assert [c.name for c in top] == ["esphome/A"] + # The incompatible dependency was dropped, not wired in. + assert top[0].dependencies == [] From 1734dc85d21ab4691290cb5fa3fce13ceef8d4b1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:42:01 +1000 Subject: [PATCH 218/282] [const][animation][dfplayer] Extract CONF_LOOP to const (#16797) --- esphome/components/animation/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/dfplayer/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index e9630f5266..9c9c7e3871 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -2,6 +2,7 @@ import logging from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT @@ -14,7 +15,6 @@ DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_LOOP = "loop" CONF_START_FRAME = "start_frame" CONF_END_FRAME = "end_frame" CONF_FRAME = "frame" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3f7777883e..ebb4186a2b 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" +CONF_LOOP = "loop" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 7796f5d891..d589381461 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_LOOP import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME @@ -15,7 +16,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" -CONF_LOOP = "loop" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" From c765e22622856c1150b5884d1bb477fee3f919ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 10:39:54 -0500 Subject: [PATCH 219/282] [ci] Exclude device-builder slow e2e tests from downstream CI (#16801) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca1fb07fda..9f227b37a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,9 +189,12 @@ jobs: - name: Run device-builder pytest # ``-n auto`` runs under pytest-xdist (matches device-builder's # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow pytest: name: Run pytest From 148a5ba68ea468eeb1d6a201d204314ef8da011b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:47:42 -0400 Subject: [PATCH 220/282] [esp32] Run clang-tidy via the native ESP-IDF toolchain (#16748) --- .clang-tidy.hash | 2 +- esphome/espidf/clang_tidy.py | 440 +++++++++++++++++++++++++++++++++++ script/clang-tidy | 26 ++- script/helpers.py | 40 ++-- sdkconfig.defaults | 15 +- 5 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 esphome/espidf/clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 648b31f8f0..c007df6b9d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a +0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py new file mode 100644 index 0000000000..2cfbe67a70 --- /dev/null +++ b/esphome/espidf/clang_tidy.py @@ -0,0 +1,440 @@ +"""Generate clang-tidy idedata via the native ESP-IDF toolchain. + +Produces idedata for clang-tidy **without an ESPHome YAML config**. Instead of +running codegen on a config, it generates a minimal ESP-IDF CMake project: + +* the managed-component dependencies come from ESPHome's own + ``idf_component.yml`` (arduinojson, lvgl, mdns, ...); +* the PlatformIO ``lib_deps`` (qr-code, mlx90393, ...) are converted to local + IDF components via the ESPHome PlatformIO->IDF converter; +* the ``main`` component ``REQUIRES`` every target-available builtin IDF + component, so their public include dirs land on the translation unit; +* the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...). + +then runs ``idf.py reconfigure`` (configure only, no compile) and reads the +resulting ``build/compile_commands.json``. The IDF version is the esp32 +component's recommended version. + +``ESPHOME_IDF_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from dataclasses import dataclass +import os +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# A do-nothing C++ app: just enough for IDF to configure a valid project. It's +# C++ (not C) so the compile command uses the C++ compiler and flags, matching +# how clang-tidy analyzes ESPHome's C++ sources. +_TIDY_MAIN_CPP = 'extern "C" void app_main() {}\n' + + +@dataclass(frozen=True) +class _Settings: + """Per-environment build settings derived from the tidy env name. + + The platform defines below are what a real ESPHome build adds via + cg.add_define; defines.h only *consumes* them, so without them + esphome/core/hal.h errors with "not implemented for this platform". + """ + + idf_target: str # esp32, esp32s3, ... + variant: str # ESP32, ESP32S3, ... + idf_version: str # ESP-IDF version to build with + target_framework: str # "espidf" or "arduino" + platform_defines: tuple[str, ...] + # Extra idf_component.yml deps the framework needs (e.g. arduino-esp32). + framework_deps: dict[str, dict] + + +def _settings_for(environment: str) -> _Settings: + """Derive build settings from a ``--tidy`` env name. + + Arduino on esp32 is itself a native ESP-IDF build with the + ``espressif/arduino-esp32`` component added, so both frameworks use this + path -- only the defines, IDF version, and that one component differ. + """ + from esphome.components.esp32 import ( + ARDUINO_FRAMEWORK_VERSION_LOOKUP, + ARDUINO_IDF_VERSION_LOOKUP, + ESP_IDF_FRAMEWORK_VERSION_LOOKUP, + ) + + parts = environment.split("-") + if len(parts) != 3 or parts[2] != "tidy" or parts[1] not in ("idf", "arduino"): + raise ValueError( + f"Unsupported clang-tidy environment {environment!r}: expected " + "--tidy with framework 'idf' or 'arduino' " + "(e.g. esp32-idf-tidy, esp32s3-arduino-tidy)" + ) + idf_target, framework, _ = parts + variant = idf_target.upper() + # Defines shared by both frameworks. ESPHOME_LOG_LEVEL must be set up front + # (as the PlatformIO tidy build_flags do) -- otherwise log.h's ``#ifndef`` + # sets it to NONE before defines.h redefines it, a macro-redefined warning + # across nearly every source. + common_defines = ( + "USE_ESP32", + f"USE_ESP32_VARIANT_{variant}", + "ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE", + ) + + if framework == "arduino": + fw_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ARDUINO_IDF_VERSION_LOOKUP[fw_version]), + target_framework="arduino", + platform_defines=( + *common_defines, + "USE_ARDUINO", + "USE_ESP32_FRAMEWORK_ARDUINO", + ), + framework_deps=_arduino_framework_deps(str(fw_version)), + ) + return _Settings( + idf_target=idf_target, + variant=variant, + idf_version=str(ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]), + target_framework="espidf", + platform_defines=( + *common_defines, + "USE_ESP_IDF", + "USE_ESP32_FRAMEWORK_ESP_IDF", + ), + framework_deps={}, + ) + + +def _arduino_framework_deps(version: str) -> dict[str, dict]: + """Arduino-only managed deps merged on top of esphome/idf_component.yml. + + arduino-esp32 provides Arduino.h and the arduino libraries; its version is + the recommended arduino framework version so the tidy build matches what + ESPHome ships. + """ + from esphome.components.esp32 import ARDUINO_ESP32_COMPONENT_NAME + + return {ARDUINO_ESP32_COMPONENT_NAME: {"version": version}} + + +_TOP_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +cmake_minimum_required(VERSION 3.16) +set(IDF_TARGET {idf_target}) +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{compile_options} +project({name}) +""" + +_MAIN_CMAKELISTS = """\ +# Auto-generated by ESPHome (clang-tidy idedata project) +idf_component_register( + SRCS "tidy.cpp" + REQUIRES {requires} +) +""" + + +def _setup_core(work_dir: Path, settings: _Settings) -> None: + """Point CORE at the tidy project + IDF version, without any YAML config.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT + import esphome.config_validation as cv + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + from esphome.core import CORE + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data dir root: the IDF install lives at + # ``/.esphome/idf`` -- keep it beside (not inside) the per-run + # project dir so clearing the project doesn't force an IDF re-download. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + esp32 = CORE.data.setdefault(KEY_ESP32, {}) + esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version) + esp32[KEY_VARIANT] = settings.variant + # The target framework drives the PlatformIO-library -> IDF-component + # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers. + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + + +# Special IDF "components" that are tools/subprojects, not requirable by an app +# (they provide no public includes and break requirement resolution), plus our +# own ``main``. +_NON_REQUIRABLE_COMPONENTS = frozenset( + {"bootloader", "esptool_py", "partition_table", "main"} +) + + +def _parse_lib_deps(platformio_ini: Path, framework: str): + """Parse the framework's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library``. The set is framework-specific: the arduino envs add + libs (FastLED, NeoPixelBus, MideaUART, ...) the idf envs don't. We read the + relevant ``[common*]`` sections directly (resolving the env's ``extends`` + chain) and skip the ``${...}`` cross-references and non-library entries. + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + sections = [("common", "lib_deps_base"), ("common", "lib_deps")] + if framework == "arduino": + sections += [ + ("common:arduino", "lib_deps"), + ("common:esp32-arduino", "lib_deps"), + ] + else: + sections += [ + ("common:idf", "lib_deps"), + ("common:esp32-idf", "lib_deps"), + ] + + tokens: list[str] = [] + for section, key in sections: + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + seen: set[str] = set() + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + # Skip blanks, ${...} cross-refs, and +<...> source filters. + if not token or token.startswith(("${", "+<")) or token in seen: + continue + seen.add(token) + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + # A bare name (SPI, Wire, WiFi, Networking, "ESP32 Async UDP", ...) is an + # Arduino framework built-in provided by arduino-esp32, not a convertible + # registry library (no owner/version), so skip it. + return libs + + +def _convert_pio_libs( + platformio_ini: Path, framework: str +) -> dict[str, dict[str, str]]: + """Convert the PlatformIO libs to IDF components; return manifest deps. + + Returns a mapping suitable for an ``idf_component.yml`` ``dependencies`` + block (``{name: {"override_path": }}``), reusing + ESPHome's own PlatformIO->IDF converter (registry/git resolution, no pio). + + The whole library set is resolved as a single batch so a shared transitive + dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard) + is deduplicated to one component instead of clashing override_path entries. + """ + from esphome.espidf.component import generate_idf_components + + libraries = _parse_lib_deps(platformio_ini, framework) + deps: dict[str, dict[str, str]] = {} + for component in generate_idf_components(libraries): + deps[component.get_sanitized_name()] = {"override_path": str(component.path)} + return deps + + +def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: + """Stub the arduino-bundled IDF components ESPHome doesn't use. + + arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome + replaces with its own library (noise-c) or doesn't use; point each at an + empty override_path component so the IDF manager doesn't resolve/download + them -- notably so ``espressif/libsodium`` doesn't clash with the converted + noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``. + + Components ESPHome's own idf_component.yml provides (e.g. lan867x for + ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32 + resolves to the same component rather than conflicting. + """ + import yaml + + from esphome.components.esp32 import ( + ARDUINO_EXCLUDED_IDF_COMPONENTS, + _idf_component_dep_name, + _idf_component_stub_name, + ) + + esphome_dir = Path(__file__).resolve().parent.parent + base_manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + esphome_deps = set(base_manifest.get("dependencies") or {}) + + stubs_dir = work_dir / "component_stubs" + stubs_dir.mkdir(parents=True, exist_ok=True) + deps: dict[str, dict] = {} + for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS): + if _idf_component_dep_name(component) in esphome_deps: + continue # ESPHome needs this one for real (don't stub it away) + stub_path = stubs_dir / _idf_component_stub_name(component) + stub_path.mkdir(exist_ok=True) + (stub_path / "CMakeLists.txt").write_text( + "idf_component_register()\n", encoding="utf-8" + ) + deps[_idf_component_dep_name(component)] = { + "version": "*", + "override_path": str(stub_path), + } + return deps + + +def _write_tidy_project( + work_dir: Path, + requires: list[str], + extra_deps: dict[str, dict[str, str]], + settings: _Settings, +) -> None: + """Generate the minimal IDF CMake project (top + main + idf_component.yml).""" + main_dir = work_dir / "main" + main_dir.mkdir(parents=True, exist_ok=True) + + compile_options = "\n".join( + f'idf_build_set_property(COMPILE_OPTIONS "-D{define}" APPEND)' + for define in settings.platform_defines + ) + (work_dir / "CMakeLists.txt").write_text( + _TOP_CMAKELISTS.format( + name=TIDY_PROJECT_NAME, + compile_options=compile_options, + idf_target=settings.idf_target, + ), + encoding="utf-8", + ) + (main_dir / "CMakeLists.txt").write_text( + _MAIN_CMAKELISTS.format(requires=" ".join(requires)), encoding="utf-8" + ) + (main_dir / "tidy.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + + # Managed components: ESPHome's own manifest (arduinojson, lvgl, mdns, ...), + # plus the converted PlatformIO libs as local (override_path) deps. Placing + # it in main/ makes every dep a requirement of the main component, so their + # public includes land on the tidy translation unit. + import yaml + + esphome_dir = Path(__file__).resolve().parent.parent # esphome/espidf -> esphome + manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + manifest.setdefault("dependencies", {}).update(extra_deps) + (main_dir / "idf_component.yml").write_text( + yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8" + ) + + # ESPHome's static-analysis sdkconfig (repo root): enables the flags any + # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components + # register and expose their includes. IDF reads ``sdkconfig.defaults`` from + # the project root. + (work_dir / "sdkconfig.defaults").write_text( + (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + +def _generate_compile_commands( + work_dir: Path, settings: _Settings, platformio_ini: Path +) -> Path: + """Generate the tidy project and run ``idf.py reconfigure`` (no build). + + Two-phase, like a real ESPHome build: a first configure with no builtin + requires discovers which components actually register for the target (e.g. + ``esp_tee`` only registers on c5/c6/h2), then a second configure requires + that discovered set so their public includes reach the tidy TU. + """ + import logging + + from esphome.build_gen.espidf import get_available_components + from esphome.espidf import toolchain + + # Surface ESPHome's INFO logs (ESP-IDF framework download/extract/install, + # git-library clones) -- they go through logging, which the clang-tidy + # script otherwise leaves at WARNING so the first-run downloads look silent. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir, settings) + + # Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local + # IDF components, all added to the manifest as deps. + extra_deps = dict(settings.framework_deps) + extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework)) + if settings.target_framework == "arduino": + # Stub the arduino-bundled components ESPHome doesn't use (avoids the + # libsodium clash with noise-c and ~26 unused heavy downloads). + extra_deps.update(_arduino_excluded_stubs(work_dir)) + + # Phase 1: discover the components available for this target. + _write_tidy_project(work_dir, [], extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure (discovery) failed") + + requires = sorted( + set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS + ) + + # Phase 2: require every available builtin component. + _write_tidy_project(work_dir, requires, extra_deps, settings) + if toolchain.run_reconfigure() != 0: + raise RuntimeError("idf.py reconfigure failed") + + return work_dir / "build" / "compile_commands.json" + + +def _idedata_from_tidy_project(compile_commands: Path) -> dict: + """Assemble idedata from the single tidy translation unit. + + Unlike a real ESPHome build (many ``/src/esphome/`` TUs unioned), the tidy + project has one TU (``main/tidy.cpp``) that -- by requiring every component -- + already carries the full include set, so we parse it directly. + """ + import json + + from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry + + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None) + if entry is None: + raise RuntimeError(f"tidy.cpp not found in {compile_commands}") + cxx_path, defines, includes, cxx_flags = _parse_entry(entry) + + return { + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": includes, + "toolchain": _get_toolchain_includes(cxx_path), + }, + } + + +def load_idedata(environment: str, temp_folder: str, platformio_ini: Path) -> dict: + if explicit := os.environ.get("ESPHOME_IDF_COMPILE_COMMANDS"): + compile_commands = Path(explicit) + else: + # The tidy env is ``--tidy`` (e.g. esp32-idf-tidy, + # esp32s3-arduino-tidy); derive the target, variant and framework. + settings = _settings_for(environment) + # Resolve to an absolute path: ``override_path`` entries in the generated + # component manifests are interpreted by the IDF component manager relative + # to the manifest's own directory, so a relative work dir would be + # mis-resolved (doubled under ``main/``). + work_dir = ( + Path(temp_folder) + / f"idf-tidy-{settings.idf_target}-{settings.target_framework}" + ).resolve() + compile_commands = _generate_compile_commands( + work_dir, settings, platformio_ini + ) + + if not compile_commands.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands}") + return _idedata_from_tidy_project(compile_commands) diff --git a/script/clang-tidy b/script/clang-tidy index 56c0a9db71..ce266e2382 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,6 +28,12 @@ from helpers import ( temp_header_file, ) +# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf +# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still +# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set +# before esphome.espidf.framework is imported (lazily, via load_idedata). +os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") + def clang_options(idedata): cmd = [] @@ -52,6 +58,10 @@ def clang_options(idedata): "-mfix-esp32-psram-cache-issue", "-mfix-esp32-psram-cache-strategy=memw", "-fno-tree-switch-conversion", + # GCC-only flags emitted by the native ESP-IDF toolchain build + "-freorder-blocks", + "-fno-jump-tables", + "-fno-shrink-wrap", ) if "zephyr" in triplet: @@ -97,8 +107,20 @@ def clang_options(idedata): ] ) - # copy compiler flags, except those clang doesn't understand. - cmd.extend(flag for flag in idedata["cxx_flags"] if flag not in omit_flags) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* + # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror + # would bypass the -clang-diagnostic-* suppressions); and -std= (the native + # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per + # platformio.ini -- analyzing as C++23 flags code that doesn't build under + # gnu++20). Force gnu++20 to match the real build. + cmd.extend( + flag + for flag in idedata["cxx_flags"] + if flag not in omit_flags + and not flag.startswith("-Werror") + and not flag.startswith("-std=") + ) + cmd.append("-std=gnu++20") # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) diff --git a/script/helpers.py b/script/helpers.py index 1ebfe405a7..8b6751c1d3 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -664,26 +664,22 @@ def load_idedata(environment: str) -> dict[str, Any]: start_time = time.time() print(f"Loading IDE data for environment '{environment}'...") - platformio_ini = Path(root_path) / "platformio.ini" + # Reuse the clang-tidy input hash as the cache key: it already covers every + # file baked into the generated idedata (platformio.ini, sdkconfig.defaults, + # esphome/idf_component.yml), so this can't drift from that file list. A + # content hash -- unlike an mtime comparison -- stays correct across git + # checkouts, which don't preserve mtimes. + from clang_tidy_hash import calculate_clang_tidy_hash + temp_idedata = Path(temp_folder) / f"idedata-{environment}.json" - changed = False - if ( - not platformio_ini.is_file() - or not temp_idedata.is_file() - or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime - ): - changed = True + temp_hash = Path(temp_folder) / f"idedata-{environment}.hash" - if "idf" in environment: - # remove full sdkconfig when the defaults have changed so that it is regenerated - default_sdkconfig = Path(root_path) / "sdkconfig.defaults" - temp_sdkconfig = Path(temp_folder) / f"sdkconfig-{environment}" - - if not temp_sdkconfig.is_file(): - changed = True - elif default_sdkconfig.stat().st_mtime >= temp_sdkconfig.stat().st_mtime: - temp_sdkconfig.unlink() - changed = True + cache_key = calculate_clang_tidy_hash() + changed = ( + not temp_idedata.is_file() + or not temp_hash.is_file() + or temp_hash.read_text().strip() != cache_key + ) if not changed: data = json.loads(temp_idedata.read_text()) @@ -694,7 +690,12 @@ def load_idedata(environment: str) -> dict[str, Any]: # ensure temp directory exists before running pio, as it writes sdkconfig to it Path(temp_folder).mkdir(exist_ok=True) - if "nrf" in environment: + platformio_ini = Path(root_path) / "platformio.ini" + if "esp32" in environment: + from esphome.espidf.clang_tidy import load_idedata as idf_load_idedata + + data = idf_load_idedata(environment, temp_folder, platformio_ini) + elif "nrf" in environment: from helpers_zephyr import load_idedata as zephyr_load_idedata data = zephyr_load_idedata(environment, temp_folder, platformio_ini) @@ -705,6 +706,7 @@ def load_idedata(environment: str) -> dict[str, Any]: match = re.search(r'{\s*".*}', stdout.decode("utf-8")) data = json.loads(match.group()) temp_idedata.write_text(json.dumps(data, indent=2) + "\n") + temp_hash.write_text(cache_key + "\n") elapsed = time.time() - start_time print(f"IDE data generated and cached in {elapsed:.2f} seconds") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2996490295..b277ed18d0 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,15 +1,11 @@ -# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used when PlatformIO is ran -# directly from the source directory, e.g. by IDEs or for static analysis (clang-tidy). This should enable all flags -# that are set by any component. +# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used for static analysis +# (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly +# from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. # esp32 -CONFIG_COMPILER_OPTIMIZATION_DEFAULT=n CONFIG_COMPILER_OPTIMIZATION_SIZE=y -CONFIG_PARTITION_TABLE_CUSTOM=y -#CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" -CONFIG_PARTITION_TABLE_SINGLE_APP=n CONFIG_FREERTOS_HZ=1000 -CONFIG_ESP_TASK_WDT=y +CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n @@ -18,8 +14,7 @@ CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n CONFIG_BT_ENABLED=y # esp32_camera -CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y -CONFIG_ESP32_SPIRAM_SUPPORT=y +CONFIG_SPIRAM=y # zigbee CONFIG_ZB_ENABLED=y From 419bde18b05fb0e162159f2ee171fbf26d9e6047 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 4 Jun 2026 16:24:47 -0400 Subject: [PATCH 221/282] [audio] Bump esp-audio-libs to v3.2.0 (#16806) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c007df6b9d..c3604e7ef2 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6 +adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c9775ab601..c051d70f3d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.1.0", + ref="3.2.0", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9476b38b72..4190c80027 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.1.0 + version: 3.2.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e2459a39235a18b0f75ce6dfd3d6d5e9792ae6d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:57:56 -0400 Subject: [PATCH 222/282] [clang-tidy] Support RISC-V targets natively (#16809) --- script/clang-tidy | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index ce266e2382..47f59e62a4 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -28,12 +28,6 @@ from helpers import ( temp_header_file, ) -# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf -# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still -# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set -# before esphome.espidf.framework is imported (lazily, via load_idedata). -os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32") - def clang_options(idedata): cmd = [] @@ -46,7 +40,14 @@ def clang_options(idedata): cmd.append("-D__XTENSA__") cmd.append("-D_LIBC") else: + # RISC-V (and other non-Xtensa targets) have a real clang backend, so + # compile for the actual triplet. Espressif's RISC-V GCC -march adds + # vendor extensions (xesploop, xespv) upstream clang doesn't know; those + # are stripped from the copied cxx_flags below. cmd.append(f"--target={triplet}") + # The GCC build passes flags (e.g. -fno-plt) that clang accepts for some + # targets but not others; don't error on the ones unused for this target. + cmd.append("-Qunused-arguments") omit_flags = ( "-free", @@ -113,8 +114,15 @@ def clang_options(idedata): # ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per # platformio.ini -- analyzing as C++23 flags code that doesn't build under # gnu++20). Force gnu++20 to match the real build. + # Strip Espressif's non-standard RISC-V -march extensions (e.g. xesploop, + # xespv); clang rejects the whole arch string otherwise. + def strip_esp_march(flag): + if flag.startswith("-march=") and triplet.startswith("riscv"): + return re.sub(r"_xesp\w+", "", flag) + return flag + cmd.extend( - flag + strip_esp_march(flag) for flag in idedata["cxx_flags"] if flag not in omit_flags and not flag.startswith("-Werror") From 5288767abf18d9109b0e821f7e70a9f9c234d67c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:58:22 -0400 Subject: [PATCH 223/282] [clang-tidy] Add --exclude-grep to skip files by content (#16813) --- script/clang-tidy | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/script/clang-tidy b/script/clang-tidy index 47f59e62a4..633b8d4b7d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -247,6 +247,12 @@ def main(): action="append", help="only run on files containing value", ) + parser.add_argument( + "-x", + "--exclude-grep", + action="append", + help="skip files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) @@ -281,6 +287,10 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + if args.exclude_grep: + excluded = set(filter_grep(files, args.exclude_grep)) + files = [f for f in files if f not in excluded] + files.sort() if args.split_num: From d2c388f8934f4508756b08684648bb2f374a716d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:01:00 -0400 Subject: [PATCH 224/282] [ota][logger][esp32][internal_temperature] Fix clang-tidy findings surfaced by RISC-V analysis (#16811) --- esphome/components/esp32/crash_handler.cpp | 5 +++++ .../internal_temperature/internal_temperature.h | 11 +++++++++++ .../internal_temperature_esp32.cpp | 12 +++--------- esphome/components/logger/logger_esp32.cpp | 4 ++-- esphome/components/ota/ota_backend_esp_idf.h | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ed61b61936..a7de48a6ee 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -41,6 +41,7 @@ static inline bool is_return_addr(uint32_t addr) { // Use memcpy for alignment safety — RISC-V C extension means code addresses // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. uint32_t inst; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode @@ -51,6 +52,7 @@ static inline bool is_return_addr(uint32_t addr) { // Check for 2-byte compressed c.jalr before this address (C extension). // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 if (addr >= 2) { + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point uint16_t c_inst = *(uint16_t *) (addr - 2); if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) return true; @@ -101,6 +103,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou out[count++] = frame->ra; } *reg_count = count; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - walking the raw stack by address is the point auto *scan_start = (uint32_t *) frame->sp; for (uint32_t i = 0; i < 64 && count < max; i++) { uint32_t val = scan_start[i]; @@ -354,6 +357,8 @@ void crash_handler_log() { #if SOC_CPU_CORES_NUM > 1 append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); +#else + (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. #endif ESP_LOGE(TAG, "%s", hint); } diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 4810e8478d..41fea5a255 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -3,6 +3,12 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" +// Every ESP32 variant except the original one exposes the on-chip sensor through +// the IDF temperature_sensor driver (the original uses the legacy temprature_sens_read). +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) +#include "driver/temperature_sensor.h" +#endif + namespace esphome::internal_temperature { class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { @@ -13,6 +19,11 @@ class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent void dump_config() override; void update() override; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) + protected: + temperature_sensor_handle_t tsens_{nullptr}; +#endif }; } // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 09121fa9c9..1c44a9a238 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -19,12 +19,6 @@ namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.esp32"; -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) -static temperature_sensor_handle_t tsensNew = NULL; -#endif // USE_ESP32_VARIANT - void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; @@ -37,7 +31,7 @@ void InternalTemperatureSensor::update() { defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ defined(USE_ESP32_VARIANT_ESP32S3) - esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); + esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { ESP_LOGE(TAG, "Reading failed (%d)", result); @@ -60,14 +54,14 @@ void InternalTemperatureSensor::setup() { defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); - esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); + esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Install failed (%d)", result); this->mark_failed(); return; } - result = temperature_sensor_enable(tsensNew); + result = temperature_sensor_enable(this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Enabling failed (%d)", result); this->mark_failed(); diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index b216a5427d..05fc959ceb 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -30,7 +30,7 @@ namespace esphome::logger { static const char *const TAG = "logger"; #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG -static void init_usb_serial_jtag_() { +static void init_usb_serial_jtag() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) @@ -109,7 +109,7 @@ void Logger::pre_setup() { #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG - init_usb_serial_jtag_(); + init_usb_serial_jtag(); #endif break; #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 73dd685df6..a49a5e34b3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,9 +54,10 @@ class IDFOTABackend final { #endif private: + // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. + md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; const esp_partition_t *partition_; - md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS From 82efa451871f89ee1693b39cfbb30aa9224c00cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:16:18 -0400 Subject: [PATCH 225/282] [multiple] Avoid float-to-double promotion in math calls (#16812) --- esphome/components/daikin_arc/daikin_arc.cpp | 2 +- esphome/components/display/display.cpp | 13 +++++++------ esphome/components/esp32/core.cpp | 4 +++- esphome/components/nau7802/nau7802.cpp | 3 ++- esphome/components/sgp4x/sgp4x.cpp | 3 ++- esphome/components/thermopro_ble/thermopro_ble.cpp | 3 ++- esphome/components/tuya/number/tuya_number.cpp | 4 +++- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index a455e2fd7f..e31f72dfb9 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -216,7 +216,7 @@ uint8_t DaikinArcClimate::temperature_() { return 0xc0; default: float new_temp = clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX); - uint8_t temperature = (uint8_t) floor(new_temp); + uint8_t temperature = (uint8_t) std::floor(new_temp); return temperature << 1 | (new_temp - temperature > 0 ? 0x01 : 0); } } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index cd2d2143f5..b24c099bce 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -1,4 +1,5 @@ #include "display.h" +#include #include #include #include "display_color_utils.h" @@ -238,7 +239,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); } else { upd_dxmax = -dxmax; } @@ -253,7 +254,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } else { if (float(dymin) > float(-dxmin) * tan_a) { - upd_dxmin = ceil(float(dymin) / tan_a); + upd_dxmin = std::ceil(float(dymin) / tan_a); } else { upd_dxmin = -dxmin; } @@ -268,12 +269,12 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax + upd_dxmax + 1; } } else { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax - upd_dxmax + 1; } else { hline_width = 0; @@ -452,8 +453,8 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; - *vertex_x = (int) round(cos(vertex_angle) * radius) + center_x; - *vertex_y = (int) round(sin(vertex_angle) * radius) + center_y; + *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; + *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } } diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 5249f4a59e..098a59937a 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -8,7 +8,9 @@ void setup(); // NOLINT(readability-redundant-declaration) -// Weak stub for initArduino - overridden when the Arduino component is present +// Weak stub for initArduino - overridden when the Arduino component is present. +// Name must match the Arduino framework's entry point, so the naming check is suppressed. +// NOLINTNEXTLINE(readability-identifier-naming) extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 4d73ed6dd0..2092452087 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,4 +1,5 @@ #include "nau7802.h" +#include #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -76,7 +77,7 @@ void NAU7802Sensor::setup() { return; } - uint32_t gcal = (uint32_t) (round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); + uint32_t gcal = (uint32_t) (std::round(this->gain_calibration_ * (1 << GCAL1_FRACTIONAL))); this->write_value_(OCAL1_B2_REG, 3, this->offset_calibration_); this->write_value_(GCAL1_B3_REG, 4, gcal); diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 94e6d69dcb..db56bd13f0 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::sgp4x { @@ -199,7 +200,7 @@ void SGP4xComponent::measure_raw_() { response_words = 2; } } - uint16_t rhticks = (uint16_t) llround((humidity * 65535) / 100); + uint16_t rhticks = (uint16_t) std::llround((humidity * 65535) / 100); uint16_t tempticks = (uint16_t) (((temperature + 45) * 65535) / 175); // first parameter are the relative humidity ticks data[0] = rhticks; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2c90ee23f8..1ccf59a2f6 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,4 +1,5 @@ #include "thermopro_ble.h" +#include #include "esphome/core/log.h" #ifdef USE_ESP32 @@ -136,7 +137,7 @@ static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { // A*tanh(B*x+C)+D // Where A,B,C,D are the variables to optimize for. This yielded the below function static float tp96_battery(uint16_t voltage) { - float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; return std::max(0.0f, std::min(level, 100.0f)); } diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index bfedbb9319..b0bbfce649 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -1,3 +1,5 @@ +#include + #include "esphome/core/log.h" #include "tuya_number.h" @@ -63,7 +65,7 @@ void TuyaNumber::setup() { void TuyaNumber::control(float value) { ESP_LOGV(TAG, "Setting number %u: %f", this->number_id_, value); if (this->type_ == TuyaDatapointType::INTEGER) { - int integer_value = lround(value * multiply_by_); + int integer_value = std::lround(value * multiply_by_); this->parent_->set_integer_datapoint_value(this->number_id_, integer_value); } else if (this->type_ == TuyaDatapointType::ENUM) { this->parent_->set_enum_datapoint_value(this->number_id_, value); From 9fbd4c38aeee6f998b2cf980dc111d9dd35529cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jun 2026 17:28:32 -0500 Subject: [PATCH 226/282] [i2s_audio] Move test bus into a shared package and give fixtures unique ids (#16793) --- script/analyze_component_buses.py | 1 + script/helpers.py | 1 + .../i2s_audio/common-spdif_mode.yaml | 2 +- tests/components/micro_wake_word/common.yaml | 7 ++---- .../micro_wake_word/test.esp32-idf.yaml | 6 +++++ .../micro_wake_word/test.esp32-s3-idf.yaml | 6 +++++ tests/components/mixer/common.yaml | 6 +---- tests/components/mixer/test.esp32-idf.yaml | 4 +--- tests/components/mixer/test.esp32-s3-idf.yaml | 6 ++--- tests/components/resampler/common.yaml | 10 +++------ .../components/resampler/test.esp32-idf.yaml | 6 ++--- .../resampler/test.esp32-s3-idf.yaml | 8 +++---- tests/components/router/common.yaml | 6 ++--- tests/components/router/test.esp32-idf.yaml | 9 ++++---- tests/components/sound_level/common.yaml | 7 ++---- .../sound_level/test.esp32-idf.yaml | 5 ++--- .../sound_level/test.esp32-s3-idf.yaml | 7 +++--- .../components/speaker/common-audio_dac.yaml | 6 +---- tests/components/speaker/common.yaml | 6 +---- .../speaker/test-audio_dac.esp32-idf.yaml | 6 ++--- .../speaker/test-media_player.esp32-idf.yaml | 10 ++++----- tests/components/speaker/test.esp32-idf.yaml | 6 ++--- tests/components/speaker_source/common.yaml | 10 +++------ .../speaker_source/test.esp32-idf.yaml | 10 ++++----- .../voice_assistant/common-idf.yaml | 22 +++++++++---------- tests/components/voice_assistant/common.yaml | 15 +++++-------- .../voice_assistant/test.esp32-idf.yaml | 12 +++++----- tests/test_build_components/common/README.md | 9 ++++++++ .../common/i2s_audio/esp32-idf.yaml | 14 ++++++++++++ .../common/i2s_audio/esp32-s3-idf.yaml | 14 ++++++++++++ 30 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 tests/test_build_components/common/i2s_audio/esp32-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fc66605694..a343e34328 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -59,6 +59,7 @@ DIRECT_BUS_TYPES = ( "modbus", "remote_transmitter", "remote_receiver", + "i2s_audio", ) # Signature for components with no bus requirements diff --git a/script/helpers.py b/script/helpers.py index 8b6751c1d3..fc2a3607fb 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -53,6 +53,7 @@ BASE_BUS_COMPONENTS = { "canbus", "remote_transmitter", "remote_receiver", + "i2s_audio", } # Cache version for components graph diff --git a/tests/components/i2s_audio/common-spdif_mode.yaml b/tests/components/i2s_audio/common-spdif_mode.yaml index 374a4bce1e..681ec2aa53 100644 --- a/tests/components/i2s_audio/common-spdif_mode.yaml +++ b/tests/components/i2s_audio/common-spdif_mode.yaml @@ -3,7 +3,7 @@ i2s_audio: speaker: - platform: i2s_audio - id: speaker_id + id: spdif_speaker_id dac_type: external i2s_dout_pin: ${spdif_data_pin} spdif_mode: true diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index cd060c176e..9ac1056ba4 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -1,14 +1,11 @@ psram: mode: quad -i2s_audio: - i2s_lrclk_pin: GPIO18 - i2s_bclk_pin: GPIO19 - microphone: - platform: i2s_audio id: echo_microphone - i2s_din_pin: GPIO17 + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${mic_din_pin} adc_type: external pdm: true bits_per_sample: 16bit diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index dade44d145..fa3984d57e 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + <<: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index dade44d145..a1b7b75423 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ +substitutions: + mic_din_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index dee42ed280..55e96df4c2 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -6,14 +6,10 @@ esphome: decibel_reduction: 10 duration: 1s -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio id: mixer_output_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} bits_per_sample: 32bit diff --git a/tests/components/mixer/test.esp32-idf.yaml b/tests/components/mixer/test.esp32-idf.yaml index 6712f1e468..ba42761635 100644 --- a/tests/components/mixer/test.esp32-idf.yaml +++ b/tests/components/mixer/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 dout_pin: GPIO14 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/mixer/test.esp32-s3-idf.yaml b/tests/components/mixer/test.esp32-s3-idf.yaml index f1721f0862..f12a9615af 100644 --- a/tests/components/mixer/test.esp32-s3-idf.yaml +++ b/tests/components/mixer/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 dout_pin: GPIO7 +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + <<: !include common.yaml diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 8ff09ed256..782dc831c4 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -1,13 +1,9 @@ -i2s_audio: - i2s_lrclk_pin: ${lrclk_pin} - i2s_bclk_pin: ${bclk_pin} - i2s_mclk_pin: ${mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: resampler_i2s_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${dout_pin} - platform: resampler id: resampler_speaker_id - output_speaker: speaker_id + output_speaker: resampler_i2s_speaker_id diff --git a/tests/components/resampler/test.esp32-idf.yaml b/tests/components/resampler/test.esp32-idf.yaml index 6712f1e468..c6bc03e661 100644 --- a/tests/components/resampler/test.esp32-idf.yaml +++ b/tests/components/resampler/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO15 - dout_pin: GPIO14 + dout_pin: GPIO21 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/resampler/test.esp32-s3-idf.yaml b/tests/components/resampler/test.esp32-s3-idf.yaml index f1721f0862..1d80d24cdf 100644 --- a/tests/components/resampler/test.esp32-s3-idf.yaml +++ b/tests/components/resampler/test.esp32-s3-idf.yaml @@ -1,7 +1,7 @@ substitutions: - lrclk_pin: GPIO4 - bclk_pin: GPIO5 - mclk_pin: GPIO6 - dout_pin: GPIO7 + dout_pin: GPIO16 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/router/common.yaml b/tests/components/router/common.yaml index f1239de3cb..5914e4ca45 100644 --- a/tests/components/router/common.yaml +++ b/tests/components/router/common.yaml @@ -8,13 +8,10 @@ esphome: - router.speaker.switch_output: target_speaker: !lambda return id(speaker_a_id); -i2s_audio: - i2s_lrclk_pin: ${a_lrclk_pin} - i2s_bclk_pin: ${a_bclk_pin} - speaker: - platform: i2s_audio id: speaker_a_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${a_dout_pin} sample_rate: 48000 @@ -22,6 +19,7 @@ speaker: channel: stereo - platform: i2s_audio id: speaker_b_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${b_dout_pin} spdif_mode: true diff --git a/tests/components/router/test.esp32-idf.yaml b/tests/components/router/test.esp32-idf.yaml index 241a9a8903..8288774941 100644 --- a/tests/components/router/test.esp32-idf.yaml +++ b/tests/components/router/test.esp32-idf.yaml @@ -1,7 +1,8 @@ substitutions: - a_lrclk_pin: GPIO4 - a_bclk_pin: GPIO5 - a_dout_pin: GPIO14 - b_dout_pin: GPIO19 + a_dout_pin: GPIO26 + b_dout_pin: GPIO27 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/common.yaml b/tests/components/sound_level/common.yaml index cc04f5bf79..eceef3a9b5 100644 --- a/tests/components/sound_level/common.yaml +++ b/tests/components/sound_level/common.yaml @@ -1,11 +1,8 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - microphone: - platform: i2s_audio id: i2s_microphone - i2s_din_pin: ${i2s_dout_pin} + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${i2s_din_pin} adc_type: external bits_per_sample: 16bit diff --git a/tests/components/sound_level/test.esp32-idf.yaml b/tests/components/sound_level/test.esp32-idf.yaml index 20e38e8df8..4d89f4cd2e 100644 --- a/tests/components/sound_level/test.esp32-idf.yaml +++ b/tests/components/sound_level/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO25 - i2s_lrclk_pin: GPIO26 - i2s_dout_pin: GPIO27 + i2s_din_pin: GPIO39 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/sound_level/test.esp32-s3-idf.yaml b/tests/components/sound_level/test.esp32-s3-idf.yaml index 9c1f32d5bd..9dfe3b4877 100644 --- a/tests/components/sound_level/test.esp32-s3-idf.yaml +++ b/tests/components/sound_level/test.esp32-s3-idf.yaml @@ -1,6 +1,7 @@ substitutions: - i2s_bclk_pin: GPIO4 - i2s_lrclk_pin: GPIO5 - i2s_dout_pin: GPIO6 + i2s_din_pin: GPIO17 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker/common-audio_dac.yaml b/tests/components/speaker/common-audio_dac.yaml index 67bd6c28ef..e3972b4da9 100644 --- a/tests/components/speaker/common-audio_dac.yaml +++ b/tests/components/speaker/common-audio_dac.yaml @@ -14,11 +14,6 @@ esphome: - speaker.finish: - speaker.stop: -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - audio_dac: - platform: aic3204 i2c_id: i2c_bus @@ -27,6 +22,7 @@ audio_dac: speaker: - platform: i2s_audio id: speaker_with_audio_dac_id + i2s_audio_id: i2s_audio_bus audio_dac: internal_dac dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 9aaf639162..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -48,13 +48,9 @@ button: data: !lambda |- return {0x01, 0x02, (uint8_t)id(my_number).state}; -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio id: speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} diff --git a/tests/components/speaker/test-audio_dac.esp32-idf.yaml b/tests/components/speaker/test-audio_dac.esp32-idf.yaml index 71c8b06e24..48c55769da 100644 --- a/tests/components/speaker/test-audio_dac.esp32-idf.yaml +++ b/tests/components/speaker/test-audio_dac.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO33 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-audio_dac.yaml diff --git a/tests/components/speaker/test-media_player.esp32-idf.yaml b/tests/components/speaker/test-media_player.esp32-idf.yaml index 4712e4bae8..9ef164bb03 100644 --- a/tests/components/speaker/test-media_player.esp32-idf.yaml +++ b/tests/components/speaker/test-media_player.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO13 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-media_player.yaml diff --git a/tests/components/speaker/test.esp32-idf.yaml b/tests/components/speaker/test.esp32-idf.yaml index 27b8604656..b6aeca4faa 100644 --- a/tests/components/speaker/test.esp32-idf.yaml +++ b/tests/components/speaker/test.esp32-idf.yaml @@ -1,10 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO12 + i2s_dout_pin: GPIO13 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 7d663b802c..d31b97553e 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -1,17 +1,13 @@ -i2s_audio: - i2s_lrclk_pin: ${i2s_bclk_pin} - i2s_bclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - speaker: - platform: i2s_audio - id: speaker_id + id: speaker_source_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} sample_rate: 48000 num_channels: 2 - platform: mixer - output_speaker: speaker_id + output_speaker: speaker_source_speaker_id source_speakers: - id: announcement_mixer_speaker_id - id: media_mixer_speaker_id diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml index e2439ebdf2..5a2fd16938 100644 --- a/tests/components/speaker_source/test.esp32-idf.yaml +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -1,9 +1,7 @@ substitutions: - scl_pin: GPIO16 - sda_pin: GPIO17 - i2s_bclk_pin: GPIO27 - i2s_lrclk_pin: GPIO26 - i2s_mclk_pin: GPIO25 - i2s_dout_pin: GPIO23 + i2s_dout_pin: GPIO22 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 0fa0903370..812e7a2314 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -11,14 +11,9 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - micro_wake_word: id: mww_id - microphone: mic_id_external + microphone: va_mic_id_external on_wake_word_detected: - voice_assistant.start: wake_word: !lambda return wake_word; @@ -27,31 +22,34 @@ micro_wake_word: microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false - platform: i2s_audio - id: mic_id_external2 + id: va_mic_id_external2 + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin2} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - - microphone: mic_id_external + - microphone: va_mic_id_external gain_factor: 4 channels: 0 - - microphone: mic_id_external2 + - microphone: va_mic_id_external2 gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s on_listening: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index d09de74396..8604bea795 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -11,30 +11,27 @@ wifi: api: -i2s_audio: - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - microphone: - platform: i2s_audio - id: mic_id_external + id: va_mic_id_external + i2s_audio_id: i2s_audio_bus i2s_din_pin: ${i2s_din_pin} adc_type: external pdm: false speaker: - platform: i2s_audio - id: speaker_id + id: va_speaker_id + i2s_audio_id: i2s_audio_bus dac_type: external i2s_dout_pin: ${i2s_dout_pin} voice_assistant: microphone: - microphone: mic_id_external + microphone: va_mic_id_external gain_factor: 4 channels: 0 - speaker: speaker_id + speaker: va_speaker_id conversation_timeout: 60s on_listening: - logger.log: "Voice assistant microphone listening" diff --git a/tests/components/voice_assistant/test.esp32-idf.yaml b/tests/components/voice_assistant/test.esp32-idf.yaml index 0cc670a77e..de2b221da7 100644 --- a/tests/components/voice_assistant/test.esp32-idf.yaml +++ b/tests/components/voice_assistant/test.esp32-idf.yaml @@ -1,9 +1,9 @@ substitutions: - i2s_lrclk_pin: GPIO4 - i2s_bclk_pin: GPIO5 - i2s_mclk_pin: GPIO15 - i2s_din_pin: GPIO13 - i2s_din_pin2: GPIO14 - i2s_dout_pin: GPIO12 + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml <<: !include common-idf.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 76f14b8664..5e925d0067 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -145,6 +145,15 @@ Same pin allocations as standard I2C, but with 10kHz frequency for components re Same UART pins as above, plus: - **flow_control_pin**: GPIO4 (all platforms) +### I2S Audio +Provides a shared `i2s_audio_bus` (clock pins only); ESP32 family only: +- **ESP32 IDF / ESP32-S3 IDF**: BCLK=GPIO5, LRCLK=GPIO4, MCLK=GPIO15 + +Each consumer keeps its own `i2s_dout_pin`/`i2s_din_pin` substitution and must use a +unique data pin, since several speakers/microphones can share one bus when grouped. +The `i2s_audio` component itself (and the isolated PDM `microphone`) keep defining the +bus inline and are not grouped. + ### BLE - **ESP32**: Shared `esp32_ble_tracker` infrastructure - Each component defines unique `ble_client` with different MAC addresses diff --git a/tests/test_build_components/common/i2s_audio/esp32-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml new file mode 100644 index 0000000000..b540ca9af0 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml new file mode 100644 index 0000000000..d6632cc264 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s3-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S3 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From a8032054ea38ef30409bf54177865a872e64d869 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:28:50 +1200 Subject: [PATCH 227/282] [light] Pass light reference into lambda light effect (#16815) --- esphome/components/light/base_light_effects.h | 6 +++--- esphome/components/light/effects.py | 5 ++++- esphome/components/light/types.py | 1 + tests/components/light/common.yaml | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index cdb9f1f666..ba3fba6c12 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -111,7 +111,7 @@ class RandomLightEffect : public LightEffect { class LambdaLightEffect : public LightEffect { public: - LambdaLightEffect(const char *name, void (*f)(bool initial_run), uint32_t update_interval) + LambdaLightEffect(const char *name, void (*f)(LightState &, bool initial_run), uint32_t update_interval) : LightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } @@ -119,7 +119,7 @@ class LambdaLightEffect : public LightEffect { const uint32_t now = millis(); if (now - this->last_run_ >= this->update_interval_ || this->initial_run_) { this->last_run_ = now; - this->f_(this->initial_run_); + this->f_(*this->state_, this->initial_run_); this->initial_run_ = false; } } @@ -129,7 +129,7 @@ class LambdaLightEffect : public LightEffect { uint32_t get_current_index() const { return this->get_index(); } protected: - void (*f_)(bool initial_run); + void (*f_)(LightState &, bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 4088a78e0d..3ae15f9ee5 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -51,6 +51,7 @@ from .types import ( FlickerLightEffect, LambdaLightEffect, LightColorValues, + LightStateRef, PulseLightEffect, RandomLightEffect, StrobeLightEffect, @@ -175,7 +176,9 @@ def register_addressable_effect( ) async def lambda_effect_to_code(config, effect_id): lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(bool, "initial_run")], return_type=cg.void + config[CONF_LAMBDA], + [(LightStateRef, "it"), (bool, "initial_run")], + return_type=cg.void, ) return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index c7385cbee3..9c1c7331d1 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") AddressableLight = light_ns.class_("AddressableLight", LightOutput, cg.Component) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index cd9b27768e..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -182,6 +182,9 @@ light: state += 1; if (state == 4) state = 0; + if (initial_run) { + ESP_LOGD("custom_effect", "Effect %s started", it.get_name().c_str()); + } - pulse: transition_length: 10s update_interval: 20s From ef64d27ed46f74437e318b88bd2817c00b493ed1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:18 -0500 Subject: [PATCH 228/282] Bump ruff from 0.15.15 to 0.15.16 (#16807) 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 203cd2bbea..9da27acc19 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.15 # also change in .pre-commit-config.yaml when updating +ruff==0.15.16 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 772cae445fdaae90f114e2bf30053339e3eb9e8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:29 -0500 Subject: [PATCH 229/282] Bump github/codeql-action from 4.36.1 to 4.36.2 (#16808) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c71d7204de..e559472b60 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{matrix.language}}" From ea3ac1ee96acc4214b2e65a32df77f0eb259c4ce Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:02:22 -0400 Subject: [PATCH 230/282] [audio] Bump esp-audio-libs to v3.2.1 (#16818) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index c3604e7ef2..1dc63cc7bb 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -adf1b0ed175c64877f959b14ff1ff8d3ba0d15bafcd86fab85a66f1d5ce953e8 +0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index c051d70f3d..2ddce577ef 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -335,7 +335,7 @@ async def to_code(config): add_idf_component( name="esphome/esp-audio-libs", - ref="3.2.0", + ref="3.2.1", ) data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4190c80027..9c87a7e5cf 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" esphome/esp-audio-libs: - version: 3.2.0 + version: 3.2.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From e209a3fa91318283672ae0bc8584eeab7d71237e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 5 Jun 2026 05:57:05 +0200 Subject: [PATCH 231/282] [usb_uart] Add FTDI FT23XX USB UART driver (#14587) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/usb_uart/__init__.py | 18 +- esphome/components/usb_uart/ft23xx.cpp | 454 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 19 + tests/components/usb_uart/common.yaml | 10 + 4 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 esphome/components/usb_uart/ft23xx.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 1cf78fdbd5..7b9c320879 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent from esphome.components.usb_host import ( get_max_packet_size, @@ -15,6 +16,7 @@ from esphome.const import ( CONF_DUMMY_RECEIVER, CONF_ID, ) +from esphome.core import CORE from esphome.cpp_types import Component AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] @@ -55,16 +57,24 @@ class Type: uart_types = ( - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 3), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) def channel_schema(channels, baud_rate_required): + # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and + # there are only a total of 8 endpoints available. + # This will need updating when the 8 channel devices that multiplex over an endpoint are added. + if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: + channels = 3 return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp new file mode 100644 index 0000000000..d57d7fe3bd --- /dev/null +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -0,0 +1,454 @@ +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" +#include "esphome/components/uart/uart_debugger.h" + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome::usb_uart { + +using namespace bytebuffer; + +// FTDI chip family identifiers. These map to USB device bcdDevice values +// and determine how baudrate divisors and clock sources are calculated. +enum ftdi_chip_type { + TYPE_AM = 0, + TYPE_BM = 1, + TYPE_2232C = 2, + TYPE_R = 3, + TYPE_2232H = 4, + TYPE_4232H = 5, + TYPE_232H = 6, + TYPE_230X = 7, +}; + +static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; + int divisor, best_divisor, best_baud, best_baud_diff; + int i; + divisor = 24000000 / baudrate; + + divisor -= am_adjust_dn[divisor & 7]; + + best_divisor = 0; + best_baud = 0; + best_baud_diff = 0; + for (i = 0; i < 2; i++) { + int try_divisor = divisor + i; + int baud_estimate; + int baud_diff; + + if (try_divisor <= 8) { + try_divisor = 8; + } else if (divisor < 16) { + try_divisor = 16; + } else { + try_divisor += am_adjust_up[try_divisor & 7]; + if (try_divisor > 0x1FFF8) { + // Round down to maximum supported divisor value (for AM) + try_divisor = 0x1FFF8; + } + } + baud_estimate = (24000000 + (try_divisor / 2)) / try_divisor; + if (baud_estimate < baudrate) { + baud_diff = baudrate - baud_estimate; + } else { + baud_diff = baud_estimate - baudrate; + } + if (i == 0 || baud_diff < best_baud_diff) { + best_divisor = try_divisor; + best_baud = baud_estimate; + best_baud_diff = baud_diff; + if (baud_diff == 0) { + break; + } + } + } + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + if (*encoded_divisor == 1) { + *encoded_divisor = 0; // 3000000 baud + } else if (*encoded_divisor == 0x4001) { + *encoded_divisor = 1; // 2000000 baud (BM only) + } + return best_baud; +} + +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { + static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + int best_baud = 0; + int divisor, best_divisor; + if (baudrate >= clk / clk_div) { + *encoded_divisor = 0; + best_baud = clk / clk_div; + } else if (baudrate >= clk / (clk_div + clk_div / 2)) { + *encoded_divisor = 1; + best_baud = clk / (clk_div + clk_div / 2); + } else if (baudrate >= clk / (2 * clk_div)) { + *encoded_divisor = 2; + best_baud = clk / (2 * clk_div); + } else { + divisor = clk * 16 / clk_div / baudrate; + if (divisor & 1) + best_divisor = divisor / 2 + 1; + else + best_divisor = divisor / 2; + if (best_divisor > 0x20000) + best_divisor = 0x1ffff; + best_baud = clk * 16 / clk_div / best_divisor; + if (best_baud & 1) + best_baud = best_baud / 2 + 1; + else + best_baud = best_baud / 2; + *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + return best_baud; +} + +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, + unsigned short *index) { + int best_baud; + unsigned long encoded_divisor; + + if (baudrate <= 0) { + return -1; + } + + static constexpr uint32_t H_CLK = 120000000; + static constexpr uint32_t C_CLK = 48000000; + if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { + if (baudrate * 10 > H_CLK / 0x3fff) { + best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + encoded_divisor |= 0x20000; /* switch on CLK/10*/ + } else + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { + best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } else { + best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + } + + *value = (unsigned short) (encoded_divisor & 0xFFFF); + if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { + *index = (unsigned short) (encoded_divisor >> 8); + *index &= 0xFF00; + *index |= (channel_index + 1); + } else + *index = (unsigned short) (encoded_divisor >> 16); + + return best_baud; +} + +static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { + int conf_offset, ep_offset; + CdcEps eps{}; + + const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx, 0, &conf_offset); + if (!intf_desc) { + ESP_LOGD(TAG, "usb_parse_interface_descriptor failed for intf_idx=%d (end of interfaces)", intf_idx); + return nullopt; + } + ESP_LOGD(TAG, + "intf_desc [idx=%d]: bInterfaceClass=%02X, bInterfaceSubClass=%02X, bInterfaceProtocol=%02X, " + "bNumEndpoints=%d, bInterfaceNumber=%d", + intf_idx, intf_desc->bInterfaceClass, intf_desc->bInterfaceSubClass, intf_desc->bInterfaceProtocol, + intf_desc->bNumEndpoints, intf_desc->bInterfaceNumber); + + std::vector endpoints; + for (uint8_t i = 0; i != intf_desc->bNumEndpoints; i++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf_desc, i, config_desc->wTotalLength, &ep_offset); + if (!ep) { + ESP_LOGE(TAG, "Ran out of endpoints at %d before finding all %d endpoints", i, intf_desc->bNumEndpoints); + return nullopt; + } + ESP_LOGD(TAG, "ep: bEndpointAddress=%02X, bmAttributes=%02X", ep->bEndpointAddress, ep->bmAttributes); + + if (ep->bmAttributes != 0x2) { + ESP_LOGD(TAG, "Skipping non-bulk endpoint: %02X", ep->bEndpointAddress); + continue; + } + endpoints.push_back(ep); + } + + const usb_ep_desc_t *ep1 = nullptr; + const usb_ep_desc_t *ep2 = nullptr; + for (const auto *ep : endpoints) { + if (ep1 == nullptr) { + ep1 = ep; + } else if (ep2 == nullptr) { + ep2 = ep; + break; + } + } + + if (ep1 == nullptr || ep2 == nullptr) { + ESP_LOGD(TAG, "Interface %d has %zu endpoints (need 2 bulk endpoints)", intf_idx, endpoints.size()); + return nullopt; + } + + ESP_LOGD(TAG, "Interface %d: ep1=0x%02X, ep2=0x%02X", intf_idx, ep1->bEndpointAddress, ep2->bEndpointAddress); + + if (ep1->bEndpointAddress & usb_host::USB_DIR_IN) { + eps.in_ep = ep1; + eps.out_ep = ep2; + ESP_LOGD(TAG, "ep1 is IN (RX): ep1=0x%02X (in_ep), ep2=0x%02X (out_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } else { + eps.out_ep = ep1; + eps.in_ep = ep2; + ESP_LOGD(TAG, "ep1 is OUT (TX): ep1=0x%02X (out_ep), ep2=0x%02X (in_ep)", ep1->bEndpointAddress, + ep2->bEndpointAddress); + } + + eps.bulk_interface_number = intf_desc->bInterfaceNumber; + return eps; +} + +std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + std::string type_string; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_active_config_descriptor failed"); + return {}; + } + if (device_desc->bcdDevice == 0x400 || (device_desc->bcdDevice == 0x200 && device_desc->iSerialNumber == 0)) { + this->chip_type_ = TYPE_BM; + type_string = "BM type chip"; + } else if (device_desc->bcdDevice == 0x200) { + this->chip_type_ = TYPE_AM; + type_string = "AM type chip"; + } else if (device_desc->bcdDevice == 0x500) { + this->chip_type_ = TYPE_2232C; + type_string = "2232C chip"; + } else if (device_desc->bcdDevice == 0x600) { + this->chip_type_ = TYPE_R; + type_string = "type R chip"; + } else if (device_desc->bcdDevice == 0x700) { + this->chip_type_ = TYPE_2232H; + type_string = "2232H chip"; + } else if (device_desc->bcdDevice == 0x800) { + this->chip_type_ = TYPE_4232H; + type_string = "4232H chip"; + } else if (device_desc->bcdDevice == 0x900) { + this->chip_type_ = TYPE_232H; + type_string = "232H type chip"; + } else if (device_desc->bcdDevice == 0x1000) { + this->chip_type_ = TYPE_230X; + type_string = "230x chip"; + } + + ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); + for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, intf_idx)) { + cdc_devs.push_back(*eps); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + } + } + return cdc_devs; +} + +int USBUartTypeFT23XX::reset(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Reset successful, setting baudrate..."); + this->set_baudrate(channel); + } + }; + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Reset control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + } else { + ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + this->set_line_properties(channel); + } + }; + if (baudrate == 0) { + baudrate = channel->baud_rate_; + } + unsigned short value, ftdi_index; + ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); + ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); + if (!ok) { + ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Line properties set, setting modem control..."); + this->set_dtr_rts(channel); + }; + + unsigned short value = channel->data_bits_; + + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + + switch (channel->stop_bits_) { + case UART_CONFIG_STOP_BITS_1: + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + + value |= (0x00 << 14); + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_.store(false); + return; + } + ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); + channel->initialised_.store(true); + this->start_input(channel); + uint8_t next_index = channel->index_ + 1; + if (next_index < this->channels_.size()) { + USBUartChannel *next_channel = this->channels_[next_index]; + ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); + this->reset(next_channel); + return; + } else { + ESP_LOGI(TAG, "All channels configured"); + } + }; + + bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1, callback); + if (!ok) { + ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); + channel->initialised_.store(false); + return -1; + } + return 0; +} + +void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { + if (!channel->initialised_.load() || channel->input_started_.load()) + return; + + const auto *ep = channel->cdc_dev_.in_ep; + + auto callback = [this, channel](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "RX Transfer failed, status=%s", esp_err_to_name(status.error_code)); + channel->input_started_.store(false); + return; + } + + size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; + + if (uart_data_len > 0) { + ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); + if (!channel->dummy_receiver_) { + // Copy the entire received UART payload into the ring buffer in one + // operation to avoid per-byte overhead and reduce the chance of + // heap activity in hot paths. + channel->input_buffer_.push(status.data + 2, uart_data_len); +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + // Debug path creates a temporary vector for logging only; this is + // acceptable because debug mode is opt-in and not used in release. + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, + std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', + channel->debug_prefix_); + } +#endif + } + } else { + ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], + channel->index_); + } + + channel->input_started_.store(false); + if (channel->dummy_receiver_ || + channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { + this->start_input(channel); + } + }; + + channel->input_started_.store(true); + this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); +} + +void USBUartTypeFT23XX::enable_channels() { + if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { + this->reset(this->channels_[0]); + } + + for (auto *channel : this->channels_) { + if (!channel->initialised_.load()) + continue; + channel->input_started_.store(false); + channel->output_started_.store(false); + } +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index fb8425f6cd..7a19aa8e4b 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -129,6 +129,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + int reset(USBUartChannel *channel); + int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties(USBUartChannel *channel); + int set_dtr_rts(USBUartChannel *channel); + + uint8_t chip_type_{255}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5869b9468b..c8c1ee7df2 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -42,3 +42,13 @@ usb_uart: baud_rate: 9600 debug: true debug_prefix: "[CP210X] " + - id: uart_6 + type: ft2232 + channels: + - id: channel_6_1 + baud_rate: 115200 + - id: channel_6_2 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: odd From cbd3aaa1e001e889eac042d22558406ef4c09bb0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:40:18 +1200 Subject: [PATCH 232/282] [ci] Add codecov.yml to enforce 100% patch coverage on PRs (#16827) --- codecov.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..f8afbbde04 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,18 @@ +coverage: + status: + patch: + default: + target: 100% + threshold: 0% + project: + default: + informational: true + +ignore: + - "esphome/components/**/*" + - "esphome/analyze_memory/**/*" + - "tests/integration/**/*" + +comment: + layout: "reach, diff, flags, files" + require_changes: true From 61bb1805b166f4a3afc19a652b490c51b6afdc14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 10:47:32 -0500 Subject: [PATCH 233/282] [api] Fix nullptr deref when client teardown reenters state dispatch (#16834) --- esphome/components/api/api_server.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 031fa342c1..ddd03ace4a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -186,8 +186,12 @@ void APIServer::remove_client_(uint8_t client_index) { if (client_index < last_index) { std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_[last_index].reset(); + // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the + // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state -> + // on_*_update iterating active_clients()). Excluding the dying slot from the active range first + // keeps that reentrant iteration from dereferencing the now-null slot. this->api_connection_count_--; + this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { From 80c84d6665a47513c406b5181b7b76304b10307c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:55:39 -0400 Subject: [PATCH 234/282] [usb_host][usb_cdc_acm][tinyusb] Fix clang-tidy findings (#16836) --- esphome/components/tinyusb/tinyusb_component.cpp | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 6 +++--- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_host/usb_host_client.cpp | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 3cefc0454a..567a84f8c3 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -6,7 +6,7 @@ namespace esphome::tinyusb { -static const char *TAG = "tinyusb"; +static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7d8caade74..56c33a708f 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -17,7 +17,7 @@ enum USBDStringDescriptor : uint8_t { SIZE = 6, }; -static const char *DEFAULT_USB_STR = "ESPHome"; +static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB : public Component { public: diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 5498c38515..592207efa8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -96,9 +96,9 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev } static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t xTicksToWait) { + TickType_t x_ticks_to_wait) { size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); if (buf == nullptr) { return ESP_FAIL; @@ -186,7 +186,7 @@ void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; - while (1) { + while (true) { // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 480fd86750..a9f07a5422 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -167,7 +167,7 @@ class USBClient : public Component { // USB task management static void usb_task_fn(void *arg); - [[noreturn]] void usb_task_loop() const; + [[noreturn]] void usb_task_loop_() const; // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4ee8e2ac5e..45e2be17c7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -236,9 +236,9 @@ void USBClient::setup() { void USBClient::usb_task_fn(void *arg) { auto *client = static_cast(arg); - client->usb_task_loop(); + client->usb_task_loop_(); } -void USBClient::usb_task_loop() const { +void USBClient::usb_task_loop_() const { while (true) { usb_host_client_handle_events(this->handle_, portMAX_DELAY); } From b0e1b94c450c4ba80ff13c04413f82884d8cdfe2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:56:48 -0400 Subject: [PATCH 235/282] [mipi_dsi][mipi_rgb][st7701s][rpi_dpi_rgb] Fix clang-tidy findings (#16837) --- esphome/components/mipi_dsi/display.py | 4 +-- esphome/components/mipi_dsi/mipi_dsi.cpp | 29 +++++++++---------- esphome/components/mipi_dsi/mipi_dsi.h | 4 +-- esphome/components/mipi_rgb/mipi_rgb.h | 8 ++--- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 5 ++-- esphome/components/st7701s/st7701s.cpp | 5 ++-- esphome/components/st7701s/st7701s.h | 1 - .../mipi_dsi/test_mipi_dsi_config.py | 6 ++-- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 3554e32299..0939d84aa5 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -65,7 +65,7 @@ DOMAIN = "mipi_dsi" LOGGER = logging.getLogger(DOMAIN) -MIPI_DSI = mipi_dsi_ns.class_("MIPI_DSI", display.Display, cg.Component) +MipiDsi = mipi_dsi_ns.class_("MipiDsi", display.Display, cg.Component) ColorOrder = display.display_ns.enum("ColorMode") ColorBitness = display.display_ns.enum("ColorBitness") @@ -114,7 +114,7 @@ def model_schema(config): schema = display.FULL_DISPLAY_SCHEMA.extend( { model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, - cv.GenerateID(): cv.declare_id(MIPI_DSI), + cv.GenerateID(): cv.declare_id(MipiDsi), cv_dimensions(CONF_DIMENSIONS): dimension_schema( model.get_default(CONF_DRAW_ROUNDING, 1) ), diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 9bd2dded2c..0ff934ae94 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -9,18 +9,18 @@ namespace esphome::mipi_dsi { static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { - auto sem = static_cast(user_ctx); + SemaphoreHandle_t sem = static_cast(user_ctx); BaseType_t need_yield = pdFALSE; xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } -void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { +void MipiDsi::smark_failed(const LogString *message, esp_err_t err) { ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); this->mark_failed(message); } -void MIPI_DSI::setup() { +void MipiDsi::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); if (!this->enable_pins_.empty()) { @@ -175,7 +175,7 @@ void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "MIPI DSI setup complete"); } -void MIPI_DSI::update() { +void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } @@ -202,8 +202,8 @@ void MIPI_DSI::update() { this->y_high_ = 0; } -void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, - display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { +void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; // if color mapping is required, pass the buck. @@ -216,8 +216,8 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } -void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad) { +void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { esp_err_t err = ESP_OK; auto bytes_per_pixel = 3 - this->color_depth_; auto stride = (x_offset + w + x_pad) * bytes_per_pixel; @@ -241,7 +241,7 @@ void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const u ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); } -bool MIPI_DSI::check_buffer_() { +bool MipiDsi::check_buffer_() { if (this->is_failed()) return false; if (this->buffer_ != nullptr) @@ -257,7 +257,7 @@ bool MIPI_DSI::check_buffer_() { return true; } -void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { +void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (!this->get_clipping().inside(x, y)) return; @@ -280,7 +280,6 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } - auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; @@ -319,7 +318,7 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (y > this->y_high_) this->y_high_ = y; } -void MIPI_DSI::fill(Color color) { +void MipiDsi::fill(Color color) { if (!this->check_buffer_()) return; @@ -359,7 +358,7 @@ void MIPI_DSI::fill(Color color) { } } -int MIPI_DSI::get_width() { +int MipiDsi::get_width() { switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: case display::DISPLAY_ROTATION_270_DEGREES: @@ -371,7 +370,7 @@ int MIPI_DSI::get_width() { } } -int MIPI_DSI::get_height() { +int MipiDsi::get_height() { switch (this->rotation_) { case display::DISPLAY_ROTATION_0_DEGREES: case display::DISPLAY_ROTATION_180_DEGREES: @@ -385,7 +384,7 @@ int MIPI_DSI::get_height() { static const uint8_t PIXEL_MODES[] = {0, 16, 18, 24}; -void MIPI_DSI::dump_config() { +void MipiDsi::dump_config() { ESP_LOGCONFIG(TAG, "MIPI_DSI RGB LCD" "\n Model: %s" diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 82827d813e..c99f69989a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,9 +35,9 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MIPI_DSI : public display::Display { +class MipiDsi : public display::Display { public: - MIPI_DSI(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) + MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 4d1d836099..dfa8a36e1a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -30,9 +30,6 @@ class MipiRgb : public display::Display { void fill(Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; - void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad); - bool check_buffer_(); display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -60,12 +57,15 @@ class MipiRgb : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } - void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); + bool check_buffer_(); + void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); InternalGPIOPin *de_pin_{nullptr}; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 00530c3f96..aacb217965 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -54,8 +54,9 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index dac8ac9dbc..3ffef86f3e 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -57,8 +57,9 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 // if color mapping is required, pass the buck. // note that endianness is not considered here - it is assumed to match! if (bitness != display::COLOR_BITNESS_565) { - return display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, - x_pad); + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; } x_start += this->offset_x_; y_start += this->offset_y_; diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index de5e4c13d4..c65a213929 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -32,7 +32,6 @@ class ST7701S : public display::Display, public: void update() override { this->do_update_(); } void setup() override; - void complete_setup_(); void loop() override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 955e945526..c14abdb4fd 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -124,15 +124,15 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "alignas(mipi_dsi::MIPI_DSI) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];" + "alignas(mipi_dsi::MipiDsi) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MipiDsi)];" in main_cpp ) assert ( - "static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" + "static mipi_dsi::MipiDsi *const p4_nano = reinterpret_cast(mipi_dsi__p4_nano__pstorage);" in main_cpp ) assert ( - "new(p4_nano) mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" + "new(p4_nano) mipi_dsi::MipiDsi(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp From 351b98689686c94793c0dfd1f07fc610e249c64a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:01:53 -0400 Subject: [PATCH 236/282] [ci] Make ESP32 IDF the comprehensive clang-tidy pass (#16823) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 67 ++++---------------- esphome/components/heatpumpir/heatpumpir.cpp | 3 +- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f227b37a8..ae3f4e2b98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,9 +482,8 @@ jobs: options: --environment esp8266-arduino-tidy --grep USE_ESP8266 pio_cache_key: tidyesp8266 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF - options: --environment esp32-idf-tidy --grep USE_ESP_IDF - pio_cache_key: tidyesp32-idf + name: Run script/clang-tidy for ESP32 Arduino + options: --environment esp32-arduino-tidy --grep USE_ARDUINO - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -505,14 +504,14 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio @@ -523,13 +522,6 @@ jobs: echo "::add-matcher::.github/workflows/matchers/gcc.json" echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" - - name: Run 'pio run --list-targets -e esp32-idf-tidy' - if: matrix.name == 'Run script/clang-tidy for ESP32 IDF' - run: | - . venv/bin/activate - mkdir -p .temp - pio run --list-targets -e esp32-idf-tidy - - name: Check if full clang-tidy scan needed id: check_full_scan run: | @@ -568,7 +560,7 @@ jobs: if: always() clang-tidy-nosplit: - name: Run script/clang-tidy for ESP32 Arduino + name: Run script/clang-tidy for ESP32 IDF runs-on: ubuntu-24.04 needs: - common @@ -589,20 +581,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,10 +609,10 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --environment esp32-idf-tidy else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -655,21 +633,18 @@ jobs: GH_TOKEN: ${{ github.token }} strategy: fail-fast: false - max-parallel: 2 + max-parallel: 3 matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 1/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 2/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 3/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 3 - - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 4/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 4 + name: Run script/clang-tidy for ESP32 IDF 3/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 3 steps: - name: Check out code from GitHub @@ -684,20 +659,6 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 8e9a2c5298..502f83cd5d 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -2,6 +2,7 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) +#include #include #include #include @@ -113,7 +114,7 @@ void HeatpumpIRClimate::setup() { this->current_temperature = state; IRSenderESPHome esp_sender(this->transmitter_); - this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature))); + this->heatpump_ir_->send(esp_sender, uint8_t(std::lround(this->current_temperature))); // current temperature changed, publish state this->publish_state(); From 42cf421f5c41c7cb71f47ef8043de0ecbde831c1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:03:22 -0400 Subject: [PATCH 237/282] [usb_uart] Fix clang-tidy findings (#16835) --- esphome/components/usb_uart/ch34x.cpp | 58 ++++++++-------- esphome/components/usb_uart/cp210x.cpp | 10 +-- esphome/components/usb_uart/ft23xx.cpp | 88 +++++++++++++----------- esphome/components/usb_uart/usb_uart.cpp | 24 +++---- esphome/components/usb_uart/usb_uart.h | 10 +-- 5 files changed, 96 insertions(+), 94 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index d5428cc8d7..c5f904ead1 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,43 +10,43 @@ namespace esphome::usb_uart { using namespace bytebuffer; struct CH34xEntry { + const char *name; uint16_t pid; uint8_t byte_idx; // which status.data[] byte to inspect uint8_t mask; // bitmask applied before comparison uint8_t match; // 0xFF = wildcard (default/fallthrough for this PID) CH34xChipType chiptype; - const char *name; uint8_t num_ports; }; static const CH34xEntry CH34X_TABLE[] = { - {0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, "CH342K", 2}, - {0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, "CH342F", 2}, - {0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, "CH343J", 1}, - {0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, "CH343K", 1}, - {0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, "CH343G_AUTOBAUD", 1}, - {0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, "CH343GP", 1}, - {0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, "CH9102X", 1}, - {0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, "CH9102F", 1}, - {0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, "CH344L", 4}, // CH344L vs CH344L_V2 resolved below - {0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, "CH344Q", 4}, - {0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, "CH9103M", 2}, - {0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, "CH9101RY", 1}, - {0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, "CH9101UH", 1}, - {0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, - {0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, - {0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, "CH339W", 1}, - {0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, "CH9104L", 4}, - {0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, "CH9111L_M0", 1}, - {0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, "CH9111L_M1", 1}, - {0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, "CH9114L", 4}, - {0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, "CH9114W", 4}, - {0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, "CH9114F", 4}, - {0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, "CH346C_M1", 1}, - {0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, "CH346C_M0", 1}, - {0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, "CH346C_M2", 2}, + {"CH342K", 0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, 2}, + {"CH342F", 0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, 2}, + {"CH343J", 0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, 1}, + {"CH343K", 0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, 1}, + {"CH343G_AUTOBAUD", 0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, 1}, + {"CH343GP", 0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, 1}, + {"CH9102X", 0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, 1}, + {"CH9102F", 0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, 1}, + {"CH344L", 0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, 4}, // CH344L vs CH344L_V2 resolved below + {"CH344Q", 0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, 4}, + {"CH9103M", 0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, 2}, + {"CH9101RY", 0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, 1}, + {"CH9101UH", 0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, 1}, + {"CH347TF", 0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, 1}, + {"CH347TF", 0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH347TF", 0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, 2}, + {"CH339W", 0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, 1}, + {"CH9104L", 0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, 4}, + {"CH9111L_M0", 0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, 1}, + {"CH9111L_M1", 0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, 1}, + {"CH9114L", 0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, 4}, + {"CH9114W", 0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, 4}, + {"CH9114F", 0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, 4}, + {"CH346C_M1", 0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, 1}, + {"CH346C_M0", 0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, 1}, + {"CH346C_M2", 0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, 2}, }; void USBUartTypeCH34X::enable_channels() { @@ -157,7 +157,7 @@ void USBUartTypeCH34X::apply_line_settings_() { this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } - this->start_channels(); + this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 261f40c0db..67fd03a813 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -65,7 +65,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev } for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) { - auto data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + const auto *data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); if (!data_desc) { ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed"); break; @@ -76,13 +76,13 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev continue; } ep_offset = conf_offset; - auto out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); + const auto *out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); if (!out_ep) { ESP_LOGE(TAG, "out_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } ep_offset = conf_offset; - auto in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); + const auto *in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); if (!in_ep) { ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; @@ -98,7 +98,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev void USBUartTypeCP210X::enable_channels() { // enable the channels - for (auto channel : this->channels_) { + for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { @@ -118,7 +118,7 @@ void USBUartTypeCP210X::enable_channels() { this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, baud.get_data()); } - this->start_channels(); + this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index d57d7fe3bd..c2c8993805 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -12,7 +12,7 @@ using namespace bytebuffer; // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. -enum ftdi_chip_type { +enum FtdiChipType { TYPE_AM = 0, TYPE_BM = 1, TYPE_2232C = 2, @@ -23,15 +23,15 @@ enum ftdi_chip_type { TYPE_230X = 7, }; -static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; - static const char am_adjust_up[8] = {0, 0, 0, 1, 0, 3, 2, 1}; - static const char am_adjust_dn[8] = {0, 0, 0, 1, 0, 1, 2, 3}; +static int ftdi_to_clkbits_am(int baudrate, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; + static const char AM_ADJUST_UP[8] = {0, 0, 0, 1, 0, 3, 2, 1}; + static const char AM_ADJUST_DN[8] = {0, 0, 0, 1, 0, 1, 2, 3}; int divisor, best_divisor, best_baud, best_baud_diff; int i; divisor = 24000000 / baudrate; - divisor -= am_adjust_dn[divisor & 7]; + divisor -= AM_ADJUST_DN[divisor & 7]; best_divisor = 0; best_baud = 0; @@ -46,7 +46,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } else if (divisor < 16) { try_divisor = 16; } else { - try_divisor += am_adjust_up[try_divisor & 7]; + try_divisor += AM_ADJUST_UP[try_divisor & 7]; if (try_divisor > 0x1FFF8) { // Round down to maximum supported divisor value (for AM) try_divisor = 0x1FFF8; @@ -67,7 +67,7 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { } } } - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 7] << 14); + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 7] << 14); if (*encoded_divisor == 1) { *encoded_divisor = 0; // 3000000 baud } else if (*encoded_divisor == 0x4001) { @@ -76,8 +76,8 @@ static int ftdi_to_clkbits_AM(int baudrate, unsigned long *encoded_divisor) { return best_baud; } -static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned long *encoded_divisor) { - static const char frac_code[8] = {0, 3, 2, 4, 1, 5, 6, 7}; +static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t *encoded_divisor) { + static const char FRAC_CODE[8] = {0, 3, 2, 4, 1, 5, 6, 7}; int best_baud = 0; int divisor, best_divisor; if (baudrate >= clk / clk_div) { @@ -91,26 +91,28 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, unsigned best_baud = clk / (2 * clk_div); } else { divisor = clk * 16 / clk_div / baudrate; - if (divisor & 1) + if (divisor & 1) { best_divisor = divisor / 2 + 1; - else + } else { best_divisor = divisor / 2; + } if (best_divisor > 0x20000) best_divisor = 0x1ffff; best_baud = clk * 16 / clk_div / best_divisor; - if (best_baud & 1) + if (best_baud & 1) { best_baud = best_baud / 2 + 1; - else + } else { best_baud = best_baud / 2; - *encoded_divisor = (best_divisor >> 3) | (frac_code[best_divisor & 0x7] << 14); + } + *encoded_divisor = (best_divisor >> 3) | (FRAC_CODE[best_divisor & 0x7] << 14); } return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, unsigned short *value, - unsigned short *index) { +static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, + uint16_t *index) { int best_baud; - unsigned long encoded_divisor; + uint32_t encoded_divisor; if (baudrate <= 0) { return -1; @@ -122,21 +124,23 @@ static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channe if (baudrate * 10 > H_CLK / 0x3fff) { best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ - } else + } else { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_AM(baudrate, &encoded_divisor); + best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (unsigned short) (encoded_divisor & 0xFFFF); + *value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (unsigned short) (encoded_divisor >> 8); + *index = (uint16_t) (encoded_divisor >> 8); *index &= 0xFF00; *index |= (channel_index + 1); - } else - *index = (unsigned short) (encoded_divisor >> 16); + } else { + *index = (uint16_t) (encoded_divisor >> 16); + } return best_baud; } @@ -248,23 +252,23 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev } ESP_LOGD(TAG, "Found FTDI %s based device", type_string.c_str()); - for (uint8_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { - if (auto eps = get_uart(config_desc, intf_idx)) { + for (size_t intf_idx = 0; intf_idx < this->channels_.size(); intf_idx++) { + if (auto eps = get_uart(config_desc, static_cast(intf_idx))) { cdc_devs.push_back(*eps); - ESP_LOGD(TAG, "Found CDC interface at USB interface index %d", intf_idx); + ESP_LOGD(TAG, "Found CDC interface at USB interface index %zu", intf_idx); } } return cdc_devs; } -int USBUartTypeFT23XX::reset(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate(channel); + this->set_baudrate_(channel); } }; bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, @@ -277,20 +281,20 @@ int USBUartTypeFT23XX::reset(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); - this->set_line_properties(channel); + this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - unsigned short value, ftdi_index; + uint16_t value, ftdi_index; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); @@ -303,18 +307,18 @@ int USBUartTypeFT23XX::set_baudrate(USBUartChannel *channel, uint32_t baudrate) return 0; } -int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); return; } ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts(channel); + this->set_dtr_rts_(channel); }; - unsigned short value = channel->data_bits_; + uint16_t value = channel->data_bits_; switch (channel->parity_) { case UART_CONFIG_PARITY_NONE: @@ -358,8 +362,8 @@ int USBUartTypeFT23XX::set_line_properties(USBUartChannel *channel) { return 0; } -int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [=, this](const usb_host::TransferStatus &status) { +int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { + usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); @@ -372,7 +376,7 @@ int USBUartTypeFT23XX::set_dtr_rts(USBUartChannel *channel) { if (next_index < this->channels_.size()) { USBUartChannel *next_channel = this->channels_[next_index]; ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset(next_channel); + this->reset_(next_channel); return; } else { ESP_LOGI(TAG, "All channels configured"); @@ -439,7 +443,7 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { void USBUartTypeFT23XX::enable_channels() { if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset(this->channels_[0]); + this->reset_(this->channels_[0]); } for (auto *channel : this->channels_) { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index e3bf5e40bc..3fdf35a472 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -141,13 +141,12 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } #ifdef USE_UART_DEBUGGER if (this->debug_) { - constexpr size_t BATCH = 16; - char buf[4 + format_hex_pretty_size(BATCH)]; // ">>> " + "XX,XX,...,XX\0" - for (size_t off = 0; off < len; off += BATCH) { - size_t n = std::min(len - off, BATCH); - memcpy(buf, ">>> ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, data + off, n, ','); - ESP_LOGD(TAG, "%s%s", this->debug_prefix_.c_str(), buf); + constexpr size_t batch = 16; + char buf[format_hex_pretty_size(batch)]; // "XX,XX,...,XX\0" + for (size_t off = 0; off < len; off += batch) { + size_t n = std::min(len - off, batch); + format_hex_pretty_to(buf, data + off, n, ','); + ESP_LOGD(TAG, "%s>>> %s", this->debug_prefix_.c_str(), buf); } } #endif @@ -222,10 +221,9 @@ void USBUartComponent::loop() { #ifdef USE_UART_DEBUGGER if (channel->debug_) { - char buf[4 + format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "<<< " + hex - memcpy(buf, "<<< ", 4); - format_hex_pretty_to(buf + 4, sizeof(buf) - 4, chunk->data, chunk->length, ','); - ESP_LOGD(TAG, "%s%s", channel->debug_prefix_.c_str(), buf); + char buf[format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "XX,XX,...,XX\0" + format_hex_pretty_to(buf, chunk->data, chunk->length, ','); + ESP_LOGD(TAG, "%s<<< %s", channel->debug_prefix_.c_str(), buf); } #endif @@ -528,10 +526,10 @@ void USBUartTypeCdcAcm::enable_channels() { } }); } - this->start_channels(); + this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels() { +void USBUartTypeCdcAcm::start_channels_() { for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 7a19aa8e4b..41dc2c546d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -214,7 +214,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { /// Resets per-channel transfer flags and posts the first bulk IN transfer. /// Called by enable_channels() and by vendor-specific subclass overrides that /// handle their own line-coding setup before starting data flow. - void start_channels(); + void start_channels_(); }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -251,10 +251,10 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; void enable_channels() override; - int reset(USBUartChannel *channel); - int set_baudrate(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties(USBUartChannel *channel); - int set_dtr_rts(USBUartChannel *channel); + int reset_(USBUartChannel *channel); + int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); + int set_line_properties_(USBUartChannel *channel); + int set_dtr_rts_(USBUartChannel *channel); uint8_t chip_type_{255}; }; From 2b581ecd3c992cc35e8e932e6ef3e16d190394c3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:20 -0400 Subject: [PATCH 238/282] [esp32] Bump platform to 55.03.39, Arduino to 3.3.9 (#16803) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 14 ++++++++------ platformio.ini | 6 +++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 1dc63cc7bb..cab077385d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0119a5940f061725291b5dfbafbd0ef843dbe2b40489f38d1d456ae81ee3dbe7 +6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 160c06534e..6ecb41bff8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -715,14 +715,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, 8), - "latest": cv.Version(3, 3, 8), - "dev": cv.Version(3, 3, 8), + "recommended": cv.Version(3, 3, 9), + "latest": cv.Version(3, 3, 9), + "dev": cv.Version(3, 3, 9), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -744,6 +745,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, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -776,7 +778,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), + 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), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -796,8 +798,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, 38, "1"), - "latest": cv.Version(55, 3, 38, "1"), + "recommended": cv.Version(55, 3, 39), + "latest": cv.Version(55, 3, 39), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/platformio.ini b/platformio.ini index 4ac60d8099..07e9b8aad3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,9 +132,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.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -167,7 +167,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; 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.38-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz From aa11ddb333184fb450853985df377fe405a5d72d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:55 -0400 Subject: [PATCH 239/282] [zigbee][openthread][esp32_hosted] Fix clang-tidy findings (#16838) --- .../update/esp32_hosted_update.cpp | 5 +++- esphome/components/openthread/openthread.cpp | 5 ++-- esphome/components/openthread/openthread.h | 2 +- .../components/openthread/openthread_esp.cpp | 5 +++- esphome/components/zigbee/zigbee_esp32.cpp | 26 +++++++++---------- esphome/components/zigbee/zigbee_esp32.h | 5 +--- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 70fa41b312..351b0869b0 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -56,7 +56,10 @@ static bool parse_version(const std::string &version_str, int &major, int &minor major = minor = patch = 0; const char *ptr = version_str.c_str(); - if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor)) + if (!parse_int(ptr, major) || *ptr != '.') + return false; + ++ptr; + if (!parse_int(ptr, minor)) return false; if (*ptr == '.') parse_int(++ptr, patch); diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 8557427096..bf14514636 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -9,6 +9,7 @@ #include #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -43,7 +44,7 @@ void OpenThreadComponent::dump_config() { } } -void OpenThreadComponent::on_state_changed_(otChangedFlags flags, void *context) { +void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { if (flags & OT_CHANGED_THREAD_ROLE) { auto *self = static_cast(context); // This runs on the OpenThread task thread with the OT lock held, @@ -241,7 +242,7 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { - factory_reset_external_callback_ = callback; + this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b42fdd2d30..5898492a50 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -46,7 +46,7 @@ class OpenThreadComponent : public Component { protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed_(otChangedFlags flags, void *context); + static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 787f2f5de8..cf1288d90c 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -179,7 +179,10 @@ void OpenThreadComponent::ot_main() { ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); // Register state change callback to update connected_ reactively instead of polling - otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed_, this); + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + } esp_openthread_launch_mainloop(); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ade9e16572..1809f181be 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -42,7 +42,7 @@ static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { } } -void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { static uint8_t steering_retry_count = 0; uint32_t *p_sg_p = signal_struct->p_app_signal; esp_err_t err_status = signal_struct->esp_err_status; @@ -183,21 +183,21 @@ esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_d esp_zb_cluster_list_t *esp_zb_cluster_list) { esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = device_id, + .app_device_id = static_cast(device_id), .app_device_version = 0}; return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); } -static void esp_zb_task_(void *pvParameters) { +static void esp_zb_task(void *pv_parameters) { if (esp_zb_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } if (global_zigbee->is_battery_powered()) { ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(0); + esp_zb_set_node_descriptor_power_source(false); } else { - esp_zb_set_node_descriptor_power_source(1); + esp_zb_set_node_descriptor_power_source(true); } esp_zb_stack_main_loop(); } @@ -218,20 +218,20 @@ void ZigbeeComponent::setup() { return; } - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; esp_zb_cfg_t zb_nwk_cfg = { .esp_zb_role = this->device_role_, .install_code_policy = false, }; #ifdef ZB_ROUTER_ROLE + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; #else + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; #endif esp_zb_init(&zb_nwk_cfg); @@ -290,7 +290,7 @@ void ZigbeeComponent::setup() { } } } - xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); + xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 03d3286ab8..34b2b827b6 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -13,7 +13,6 @@ #include "ha/esp_zigbee_ha_standard.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" #include "zigbee_helpers_esp32.h" #ifdef USE_BINARY_SENSOR @@ -99,8 +98,6 @@ class ZigbeeComponent : public Component { CallbackManager join_cb_{}; }; -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); - template void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value) { @@ -129,7 +126,7 @@ template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; From 2ab4399ae51a02be3b0931f9cb4208593ad9931c Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:31:17 -0700 Subject: [PATCH 240/282] [qmp6988] fix false report of software reset error (#16843) --- esphome/components/qmp6988/qmp6988.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 8c8a04c5b7..293d8aa648 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -216,10 +216,7 @@ int32_t QMP6988Component::get_compensated_pressure_(qmp6988_ik_data_t *ik, int32 } void QMP6988Component::software_reset_() { - uint8_t ret = 0; - - ret = this->write_byte(QMP6988_RESET_REG, 0xe6); - if (ret != i2c::ERROR_OK) { + if (!this->write_byte(QMP6988_RESET_REG, 0xe6)) { ESP_LOGE(TAG, "Software Reset (0xe6) failed"); } delay(10); From 4cb6f2c04609752711bc72d006146f43ace6ca2d Mon Sep 17 00:00:00 2001 From: Ross Tyler Date: Fri, 5 Jun 2026 11:35:33 -0700 Subject: [PATCH 241/282] [qmp6988] fix publishing bogus zero values on i2c error (#16840) --- esphome/components/qmp6988/qmp6988.cpp | 15 ++++++++++----- esphome/components/qmp6988/qmp6988.h | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 293d8aa648..bb47e7b0f5 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -280,20 +280,21 @@ void QMP6988Component::calculate_altitude_(float pressure, float temp) { this->qmp6988_data_.altitude = altitude; } -void QMP6988Component::calculate_pressure_() { +bool QMP6988Component::calculate_pressure_() { uint8_t err = 0; uint32_t p_read, t_read; int32_t p_raw, t_raw; uint8_t a_data_uint8_tr[6] = {0}; int32_t t_int, p_int; - this->qmp6988_data_.temperature = 0; - this->qmp6988_data_.pressure = 0; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - return; + this->status_set_warning(); + return false; } + this->status_clear_warning(); + p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); @@ -305,6 +306,7 @@ void QMP6988Component::calculate_pressure_() { this->qmp6988_data_.temperature = (float) t_int / 256.0f; this->qmp6988_data_.pressure = (float) p_int / 16.0f; + return true; } void QMP6988Component::setup() { @@ -336,7 +338,10 @@ void QMP6988Component::dump_config() { } void QMP6988Component::update() { - this->calculate_pressure_(); + if (!this->calculate_pressure_()) { + return; + } + float pressurehectopascals = this->qmp6988_data_.pressure / 100; float temperature = this->qmp6988_data_.temperature; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 26f858b5d2..41759478b8 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -98,7 +98,7 @@ class QMP6988Component : public PollingComponent, public i2c::I2CDevice { void write_oversampling_temperature_(QMP6988Oversampling oversampling_t); void write_oversampling_pressure_(QMP6988Oversampling oversampling_p); void write_filter_(QMP6988IIRFilter filter); - void calculate_pressure_(); + bool calculate_pressure_(); void calculate_altitude_(float pressure, float temp); int32_t get_compensated_pressure_(qmp6988_ik_data_t *ik, int32_t dp, int16_t tx); From 77f644f57649bf2bc3bf564fe7aff3defb67b874 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:42:51 -0400 Subject: [PATCH 242/282] [ci] Share a cached native ESP-IDF install across clang-tidy and build jobs (#16841) --- .github/actions/cache-esp-idf/action.yml | 46 +++++++++++++++++++ .github/workflows/ci.yml | 56 ++++++++++++++++-------- 2 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 .github/actions/cache-esp-idf/action.yml diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml new file mode 100644 index 0000000000..7a17c222a3 --- /dev/null +++ b/.github/actions/cache-esp-idf/action.yml @@ -0,0 +1,46 @@ +name: Cache ESP-IDF +description: > + Resolve the pinned ESP-IDF version and cache the native ESP-IDF install + (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF + natively (clang-tidy for IDF/Arduino and the native-IDF component build) + shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", so all toolchains are present regardless of the chip). + Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the + Python venv already restored. +inputs: + framework: + description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' + default: espidf +runs: + using: composite + steps: + - name: Resolve ESP-IDF version for cache key + # The native-IDF version is pinned in code, not in any file that feeds the + # other cache keys, so resolve it explicitly. Keying on it means the cache + # invalidates on a version bump (actions/cache never overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + if [ "${{ inputs.framework }}" = "arduino" ]; then + version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])') + else + version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])') + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror the adjacent PlatformIO cache: only dev-branch runs write the + # shared cache (so it lives in the default-branch scope readable by all + # PRs), and PRs are restore-only -- they never push multi-GB artifacts into + # their own scope / the repo quota (e.g. on a version-bump PR). + - name: Cache ESP-IDF install (write on dev) + if: github.ref == 'refs/heads/dev' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} + - name: Cache ESP-IDF install (restore-only off dev) + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3f4e2b98..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -472,6 +472,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy == 'true' env: GH_TOKEN: ${{ github.token }} + # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 2 @@ -484,6 +486,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino options: --environment esp32-arduino-tidy --grep USE_ARDUINO + cache_idf: true - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 @@ -517,6 +520,13 @@ jobs: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + - name: Cache ESP-IDF install + # Shared with the IDF tidy + native-IDF build jobs (same install). + if: matrix.cache_idf + uses: ./.github/actions/cache-esp-idf + with: + framework: arduino + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -568,6 +578,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -581,6 +593,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -631,6 +647,8 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'split' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: 3 @@ -659,6 +677,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install + # Shared with the Arduino tidy + native-IDF build jobs (same install). + uses: ./.github/actions/cache-esp-idf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -785,7 +807,7 @@ jobs: fi echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" @@ -861,33 +883,20 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache ESPHome - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} - - - name: Run native ESP-IDF compile test + - name: Prepare build storage on /mnt + # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE + # restoring the cache, so the ~4.5GB restore lands on the roomier volume + # instead of being shadowed by a mount set up later in the run step. run: | - . venv/bin/activate - - # Check if /mnt has more free space than / before bind mounting - # Extract available space in KB for comparison root_avail=$(df -k / | awk 'NR==2 {print $4}') mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - - # Only use /mnt if it has more space than / if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then echo "Using /mnt for build files (more space available)" - # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) sudo mkdir -p /mnt/esphome-idf sudo chown $USER:$USER /mnt/esphome-idf mkdir -p ~/.esphome-idf sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - - # Bind mount test build directory to /mnt sudo mkdir -p /mnt/test_build_components_build sudo chown $USER:$USER /mnt/test_build_components_build mkdir -p tests/test_build_components/build @@ -896,10 +905,19 @@ jobs: echo "Using / for build files (more space available than /mnt or /mnt unavailable)" fi + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs (same install); restores + # into the /mnt bind-mount prepared above when present. + uses: ./.github/actions/cache-esp-idf + + - name: Run native ESP-IDF compile test + run: | + . venv/bin/activate + echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" From b63e327ae35186c35771d572b3d95bf6f2e19b98 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 5 Jun 2026 17:22:03 -0400 Subject: [PATCH 243/282] [audio] Deprecate unused scale_audio_samples helper (#16831) --- esphome/components/audio/audio.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 62c57b18cf..36780a3055 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #include #include @@ -143,6 +144,8 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url); /// @param output_buffer Buffer to store the scaled samples /// @param scale_factor Q15 fixed point scaling factor /// @param samples_to_scale Number of samples to scale +// Remove before 2026.12.0 +ESPDEPRECATED("Use esp_audio_libs::gain::apply() (from ) instead. Removed in 2026.12.0.", "2026.6.0") void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor, size_t samples_to_scale); From 913b9f5ca442c44e9c7589883a715120dc5d70be Mon Sep 17 00:00:00 2001 From: i-am-no-magic Date: Fri, 5 Jun 2026 23:37:40 +0200 Subject: [PATCH 244/282] [tuya] Fixed hysteresis bug for Tuya climate (#16832) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/tuya/climate/tuya_climate.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 7dbf33878a..111d090c3e 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -513,14 +513,14 @@ void TuyaClimate::compute_state_() { } else { // Fallback to active state calc based on temp and hysteresis const float temp_diff = this->target_temperature - this->current_temperature; - if (std::abs(temp_diff) > this->hysteresis_) { - if (this->supports_heat_ && temp_diff > 0) { - target_action = climate::CLIMATE_ACTION_HEATING; - this->mode = climate::CLIMATE_MODE_HEAT; - } else if (this->supports_cool_ && temp_diff < 0) { - target_action = climate::CLIMATE_ACTION_COOLING; - this->mode = climate::CLIMATE_MODE_COOL; - } + if ((this->supports_heat_ && temp_diff >= this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_HEATING && temp_diff > 0)) { + target_action = climate::CLIMATE_ACTION_HEATING; + this->mode = climate::CLIMATE_MODE_HEAT; + } else if ((this->supports_cool_ && temp_diff <= -this->hysteresis_) || + (this->action == climate::CLIMATE_ACTION_COOLING && temp_diff < 0)) { + target_action = climate::CLIMATE_ACTION_COOLING; + this->mode = climate::CLIMATE_MODE_COOL; } } From 93334d4e606dd4c710c9ccca411c966825baa879 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:44:31 +1000 Subject: [PATCH 245/282] [scripts] Fix build_language_schema (#16816) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- script/build_language_schema.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 6e4000e06e..025186299d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -924,9 +924,14 @@ def convert(schema, config_var, path): config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) elif schema_type == "maybe": - config_var[S_TYPE] = S_SCHEMA + # maybe_simple_value: either a scalar shorthand (mapped to the key in + # data[1]) or the full wrapped schema. The wrapped schema is usually a + # plain Schema (converts to a "schema" config var), but may be something + # else, e.g. a typed_schema (converts to a "typed" config var with + # "types" and no top-level "schema" key). Merge whatever it produced + # rather than assuming a "schema" key is present. config_var["maybe"] = data[1] - config_var["schema"] = convert_config(data[0], path + "/maybe")["schema"] + config_var.update(convert_config(data[0], path + "/maybe")) # esphome/on_boot elif schema_type == "automation": extra_schema = None From 85fd83288da8ce5985f6b960c93d97682f8b7c91 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:29:33 -0400 Subject: [PATCH 246/282] [esp32_camera] Bump esp32-camera to 2.1.7 (#16846) --- .clang-tidy.hash | 2 +- esphome/components/camera_encoder/__init__.py | 9 ++------- esphome/components/esp32/__init__.py | 5 ++++- esphome/components/esp32_camera/__init__.py | 10 ++-------- esphome/components/zigbee/zigbee_esp32.py | 7 +++---- esphome/idf_component.yml | 2 +- 6 files changed, 13 insertions(+), 22 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index cab077385d..0782b065f3 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -6f2f1745246a413712801462c8a02b92aae003d75b6cf45ca1a3cb2996b41f57 +0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 7d4cdc881e..344248fcf3 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -1,8 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import ( - add_idf_component, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType @@ -53,9 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6ecb41bff8..7e7b127814 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1375,8 +1375,11 @@ def require_libc_picolibc_newlib_compat() -> None: """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. Call this from components that link against precompiled Newlib binaries - referencing types/symbols the shim provides (e.g. esp32-camera). + referencing types/symbols the shim provides (e.g. zigbee). No-op on + IDF < 6.0.0. """ + if idf_version() < cv.Version(6, 0, 0): + return CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 763a1f3405..c3b35a8279 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,11 +3,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import ( - add_idf_component, - add_idf_sdkconfig_option, - require_libc_picolibc_newlib_compat, -) +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -403,11 +399,9 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) - # esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream - require_libc_picolibc_newlib_compat() for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index a0fadbce8b..086cdcc267 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - idf_version, + require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -240,9 +240,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: # dynamic log level control to be enabled add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility. - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True) + # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). + require_libc_picolibc_newlib_compat() async def attributes_to_code( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9c87a7e5cf..3a5b050072 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.5 + version: 2.1.7 espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: From f18cf954bae11aa10f1ef574ab4ec5a3313a0c87 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:30:26 -0400 Subject: [PATCH 247/282] [improv_serial] Fix build on ESP32-C5/P4 and simplify variant guards (#16833) --- .../components/improv_serial/improv_serial_component.cpp | 9 +++------ .../components/improv_serial/improv_serial_component.h | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 18d0b44701..206df2c844 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -68,11 +68,9 @@ optional ImprovSerialComponent::read_byte_() { switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && - // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 +#endif if (this->uart_num_ >= 0) { size_t available; uart_get_buffered_data_len(this->uart_num_, &available); @@ -136,8 +134,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 2f1d0136a4..c58c42f0d8 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,7 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) +#ifdef USE_LOGGER_USB_SERIAL_JTAG #include #include #endif From 70d9ab25f3e20f8301ab353ce45dd543e61087db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:57:42 -0500 Subject: [PATCH 248/282] [tests] Fail component test merge on conflicting duplicate IDs (#16795) --- .github/workflows/ci.yml | 1 + script/ci_check_duplicate_test_ids.py | 122 ++++++++++++++++++ script/merge_component_configs.py | 48 ++++++- tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +-- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 +++++++++++ 115 files changed, 482 insertions(+), 252 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..96c205fb70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..9d498dd64f --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge silently keeps the +first and drops the rest, which can make a cross-reference resolve to an +incompatible entity (this is what broke the i2s_audio speaker tests). The merge +now raises on such a collision, but only when the two components land in the same +group. This script is the complete, batch-independent guard: it scans every +component's ``test..yaml`` per platform and reports any id that is +defined by more than one component with differing content. + +Ids that are intentionally shared across components (e.g. a singleton +``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from script.merge_component_configs import ( # noqa: E402 + INTENTIONALLY_SHARED_IDS, + load_yaml_file, +) + +TESTS_DIR = Path("tests/components") + + +def _normalize(value: object) -> object: + """Return a hashable, order-independent representation for comparison.""" + if isinstance(value, dict): + return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_normalize(v) for v in value) + # Scalars (and ESPHome tag objects like !lambda) compare by their text form + return str(value) + + +def _collect_ids( + data: object, section: str, out: dict[tuple[str, str], object] +) -> None: + """Walk a parsed config and record (section, id) -> normalized content.""" + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + out[(key, str(item["id"]))] = _normalize(item) + _collect_ids(item, key, out) + else: + _collect_ids(value, key, out) + elif isinstance(data, list): + for item in data: + _collect_ids(item, section, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def main() -> int: + conflicts: list[str] = [] + for platform in sorted(_discover_platforms()): + # (section, id) -> {normalized_content: [components]} + by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( + lambda: defaultdict(list) + ) + for comp_dir in sorted(TESTS_DIR.iterdir()): + if not comp_dir.is_dir(): + continue + test_file = comp_dir / f"test.{platform}.yaml" + if not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except Exception as err: # noqa: BLE001 + print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) + continue + ids: dict[tuple[str, str], object] = {} + _collect_ids(data, "", ids) + for (section, id_), content in ids.items(): + if id_ in INTENTIONALLY_SHARED_IDS: + continue + by_id[(section, id_)][content].append(comp_dir.name) + + for (section, id_), variants in sorted(by_id.items()): + if len(variants) < 2: + continue + components = sorted({c for comps in variants.values() for c in comps}) + conflicts.append( + f"[{platform}] id '{id_}' under '{section}' is defined " + f"differently by: {', '.join(components)}" + ) + + if conflicts: + print("Conflicting test component ids found:\n") + for line in conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + return 1 + + print("No conflicting test component ids found.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..20457e906a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,42 @@ def prefix_substitutions_in_dict( return data +# Ids that several components intentionally share. ESPHome treats these as a +# single instance when merged (e.g. multiple components each declaring the same +# `sntp_time` clock collapse into one), so duplicates with differing content are +# expected and must not be flagged as accidental collisions. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + "sntp_time", + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + "ldo_id", + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items that + share an ID but differ in content are a real conflict: when merged, the first + one silently wins and the others are dropped, which can make cross-references + resolve to an incompatible entity. Rather than defer that to downstream + validation (where it surfaces as a confusing, order-dependent failure), raise + immediately so the offending ID is named. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +205,26 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif item_id in INTENTIONALLY_SHARED_IDS: + # Designed singleton shared by several components (e.g. an + # `sntp_time` clock); ESPHome collapses these, so keep first. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index a722a5f7c2..6617671972 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: main_lcd + id: display_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..a7740e771d 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index 93adcf9988..d99f8ddc4e 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index 6a308b67dd..e51240f20d 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 01b83c4ab8..0562a4ba51 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: main_lcd + id: mapping_main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed..9eadd97a6d 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: main_lcd + id: nextion_main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 190abbc7b1..70a0809883 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index 385fee359e..f0b44ce8c3 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend main_lcd + - id: !extend nextion_main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..bd72abbd17 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 + red: rgb_light_output_1 + green: rgb_light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..46d8082706 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 + red: rgbct_light_output_1 + green: rgbct_light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..4a8e56a255 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 + red: rgbw_light_output_1 + green: rgbw_light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..bb1d73b3bc 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 + red: rgbww_light_output_1 + green: rgbww_light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..f1387a7afe 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..9286380de1 --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,72 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """Allowlisted singleton ids may differ across components and collapse.""" + shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) + data = { + "time": [ + {"id": shared, "platform": "sntp"}, + {"id": shared, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result["time"] == [{"id": shared, "platform": "sntp"}] + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From 8f8a70b2be9e10cd81053420c4bc7a04e3d92b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 17:58:50 -0500 Subject: [PATCH 249/282] Exit nginx bypass placeholder cleanly on SIGTERM (#16845) --- .../ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run index bb5f52e10c..b8251e8e01 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -6,11 +6,15 @@ # ============================================================================== # The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun forever so s6 keeps the dependency satisfied and does -# not respawn it. +# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on +# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish +# stamp the container exit 143, which trips the Supervisor's SIGTERM check. if bashio::config.true 'use_new_device_builder'; then bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - exec sleep infinity + trap 'exit 0' TERM + sleep infinity & + wait + exit 0 fi bashio::log.info "Waiting for ESPHome dashboard to come up..." From 2a4913713a9ccb4799ec5ee8c7800a52c2070b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jun 2026 19:37:19 -0500 Subject: [PATCH 250/282] Revert "[tests] Fail component test merge on conflicting duplicate IDs" (#16848) --- .github/workflows/ci.yml | 1 - script/ci_check_duplicate_test_ids.py | 122 ------------------ script/merge_component_configs.py | 48 +------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 ++-- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/display/common.yaml | 2 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 ++- tests/components/gt911/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/lcd_menu/common.yaml | 4 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/mapping/test.esp32-idf.yaml | 2 +- .../components/mapping/test.esp8266-ard.yaml | 2 +- tests/components/mapping/test.rp2040-ard.yaml | 2 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/nextion/common.yaml | 4 +- .../components/nextion/common_tft_upload.yaml | 2 +- .../nextion/common_tft_upload_watchdog.yaml | 2 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 8 +- tests/components/rgbct/common.yaml | 8 +- tests/components/rgbw/common.yaml | 8 +- tests/components/rgbww/common.yaml | 8 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 14 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- tests/script/test_merge_component_configs.py | 72 ----------- 115 files changed, 252 insertions(+), 482 deletions(-) delete mode 100755 script/ci_check_duplicate_test_ids.py delete mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96c205fb70..40267240d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,6 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check - script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py deleted file mode 100755 index 9d498dd64f..0000000000 --- a/script/ci_check_duplicate_test_ids.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when two component test fixtures define the same id with different content. - -Component tests are merged and built in groups in CI (see -``script/merge_component_configs.py``). When two components declare the same id -under the same section but with different content, the merge silently keeps the -first and drops the rest, which can make a cross-reference resolve to an -incompatible entity (this is what broke the i2s_audio speaker tests). The merge -now raises on such a collision, but only when the two components land in the same -group. This script is the complete, batch-independent guard: it scans every -component's ``test..yaml`` per platform and reports any id that is -defined by more than one component with differing content. - -Ids that are intentionally shared across components (e.g. a singleton -``sntp_time`` clock) are listed in ``INTENTIONALLY_SHARED_IDS`` and skipped. -""" - -from __future__ import annotations - -from collections import defaultdict -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from script.merge_component_configs import ( # noqa: E402 - INTENTIONALLY_SHARED_IDS, - load_yaml_file, -) - -TESTS_DIR = Path("tests/components") - - -def _normalize(value: object) -> object: - """Return a hashable, order-independent representation for comparison.""" - if isinstance(value, dict): - return tuple(sorted((str(k), _normalize(v)) for k, v in value.items())) - if isinstance(value, (list, tuple)): - return tuple(_normalize(v) for v in value) - # Scalars (and ESPHome tag objects like !lambda) compare by their text form - return str(value) - - -def _collect_ids( - data: object, section: str, out: dict[tuple[str, str], object] -) -> None: - """Walk a parsed config and record (section, id) -> normalized content.""" - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, list): - for item in value: - if isinstance(item, dict) and "id" in item: - out[(key, str(item["id"]))] = _normalize(item) - _collect_ids(item, key, out) - else: - _collect_ids(value, key, out) - elif isinstance(data, list): - for item in data: - _collect_ids(item, section, out) - - -def _discover_platforms() -> set[str]: - platforms: set[str] = set() - for test_file in TESTS_DIR.glob("*/test.*.yaml"): - # test..yaml -> platform is the middle dotted part - parts = test_file.name.split(".") - if len(parts) == 3: - platforms.add(parts[1]) - return platforms - - -def main() -> int: - conflicts: list[str] = [] - for platform in sorted(_discover_platforms()): - # (section, id) -> {normalized_content: [components]} - by_id: dict[tuple[str, str], dict[object, list[str]]] = defaultdict( - lambda: defaultdict(list) - ) - for comp_dir in sorted(TESTS_DIR.iterdir()): - if not comp_dir.is_dir(): - continue - test_file = comp_dir / f"test.{platform}.yaml" - if not test_file.exists(): - continue - try: - data = load_yaml_file(test_file) - except Exception as err: # noqa: BLE001 - print(f"WARNING: could not parse {test_file}: {err}", file=sys.stderr) - continue - ids: dict[tuple[str, str], object] = {} - _collect_ids(data, "", ids) - for (section, id_), content in ids.items(): - if id_ in INTENTIONALLY_SHARED_IDS: - continue - by_id[(section, id_)][content].append(comp_dir.name) - - for (section, id_), variants in sorted(by_id.items()): - if len(variants) < 2: - continue - components = sorted({c for comps in variants.values() for c in comps}) - conflicts.append( - f"[{platform}] id '{id_}' under '{section}' is defined " - f"differently by: {', '.join(components)}" - ) - - if conflicts: - print("Conflicting test component ids found:\n") - for line in conflicts: - print(f" - {line}") - print( - "\nGive each component a unique id (e.g. '_'), or add the " - "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " - "it is a deliberately shared singleton." - ) - return 1 - - print("No conflicting test component ids found.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 20457e906a..a952ecff16 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,42 +161,18 @@ def prefix_substitutions_in_dict( return data -# Ids that several components intentionally share. ESPHome treats these as a -# single instance when merged (e.g. multiple components each declaring the same -# `sntp_time` clock collapse into one), so duplicates with differing content are -# expected and must not be flagged as accidental collisions. -INTENTIONALLY_SHARED_IDS = frozenset( - { - # Several components each declare an `sntp_time` clock; ESPHome merges - # them into one time source. - "sntp_time", - # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the - # ESP32-P4; only one LDO per channel may exist, so the shared id lets the - # merge collapse them into a single LDO. - "ldo_id", - } -) - - def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Identical items sharing an ID (e.g. a shared bus from a common package pulled - in by several components) are collapsed to the first occurrence. Two items that - share an ID but differ in content are a real conflict: when merged, the first - one silently wins and the others are dropped, which can make cross-references - resolve to an incompatible entity. Rather than defer that to downstream - validation (where it surfaces as a confusing, order-dependent failure), raise - immediately so the offending ID is named. + Keeps only the first occurrence of each ID. If items with the same ID + are identical, this silently deduplicates. If they differ, the first + one is kept (ESPHome's validation will catch if this causes issues). Args: data: Parsed config dictionary Returns: Config with deduplicated lists - - Raises: - ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -205,26 +181,16 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_items = {} + seen_ids = set() deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_items: - seen_items[item_id] = item + if item_id not in seen_ids: + seen_ids.add(item_id) deduped_list.append(item) - elif item_id in INTENTIONALLY_SHARED_IDS: - # Designed singleton shared by several components (e.g. an - # `sntp_time` clock); ESPHome collapses these, so keep first. - pass - elif item != seen_items[item_id]: - raise ValueError( - f"Conflicting definitions for id '{item_id}' under " - f"'{key}' when merging test configs; give each " - f"component a unique id" - ) - # else: identical duplicate (e.g. shared bus package) -> skip + # else: skip duplicate ID (keep first occurrence) else: # No ID, just add it deduped_list.append(item) diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 09ef0e1fad..0645333a81 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index f31e0e087d..ff1e3bb919 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index 77cf50d17c..b77dc299c2 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index a3019466b5..e764f0fe21 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index d899259773..face38b647 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 617464818b..4cc865bb5d 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: adc_my_sensor + - id: my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6ca..39d5739255 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: alarm_control_panel_bin1 + id: bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: alarm_control_panel_bin1 + - input: bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index b844f5ae92..c28e9584dd 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index a7937ffca2..11a7117d91 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2cbb254adf..2c99e937f3 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: animation_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index 03e82ab26e..d4fd3becbb 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: axs15231_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index 245b87bec9..eb599da773 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: axs15231_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: axs15231_ssd1306_display + display: ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 28798f8173..5882025191 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: bang_bang_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: bang_bang_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: bang_bang_template_sensor1 - humidity_sensor: bang_bang_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: bang_bang_template_switch1 + - switch.turn_on: template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..e3fd159b08 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: binary_sensor_some_binary_sensor + id: some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(binary_sensor_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.invalidate_state: some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index 667d0be9e7..c054022583 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: binary_sensor_map_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: binary_sensor_map_bin2 + id: bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: binary_sensor_map_bin3 + id: bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 value: 10.0 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 value: 15.0 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: binary_sensor_map_bin1 + - binary_sensor: bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: binary_sensor_map_bin2 + - binary_sensor: bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: binary_sensor_map_bin3 + - binary_sensor: bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ed6ad7fc9..4ea1dd60f3 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: ble_client_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(ble_client_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608..e779f7f078 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: canbus_esp32_internal_can + id: esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: canbus_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(canbus_test_number).state; + uint8_t val = (uint8_t)id(test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index e0bc185d2c..37011b16ee 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: climate_ir_lg_temp_sensor + id: temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: climate_ir_lg_temp_sensor + sensor: temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index 0db54d10d0..fe0c5bf917 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: color_temperature_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: color_temperature_light_output_2 + id: light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: color_temperature_light_output_1 - brightness: color_temperature_light_output_2 + color_temperature: light_output_1 + brightness: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index cbd056f070..a376004b2f 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: copy_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: copy_fan_speed - output: copy_fan_output_1 + id: fan_speed + output: fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: copy_fan_speed + source_id: fan_speed name: Fan Speed Copy select: diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 139571ccec..503c4596e9 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: current_based_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: current_based_template_switch1 + - switch.turn_on: template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: current_based_template_switch1 + - switch.turn_off: template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index bbb6c9182b..7fa5ab668c 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: cwww_light_output_1 - warm_white: cwww_light_output_2 + cold_white: light_output_1 + warm_white: light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 0665879b08..01edf0b0b5 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index bb1868fdef..49d73b7d3d 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index 27fc930b68..ba8e0ad071 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: cwww_light_output_1 + id: light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: cwww_light_output_2 + id: light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/display/common.yaml b/tests/components/display/common.yaml index 6617671972..a722a5f7c2 100644 --- a/tests/components/display/common.yaml +++ b/tests/components/display/common.yaml @@ -1,6 +1,6 @@ display: - platform: ili9xxx - id: display_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c49..761d10f16a 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: duty_time_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: duty_time_bin1 + sensor: bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..25fe3b6796 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: e131_led_strip + id: led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 070b03eeb9..1c4d768b08 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ektf2232_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ektf2232_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index 6f5cf61268..b92b1e13b9 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: endstop_bin1 + id: bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: endstop_template_switch1 + id: template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: endstop_template_switch1 - open_endstop: endstop_bin1 + - switch.turn_on: template_switch1 + open_endstop: bin1 open_action: - - switch.turn_on: endstop_template_switch1 + - switch.turn_on: template_switch1 open_duration: 5min - close_endstop: endstop_bin1 + close_endstop: bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d84..3b9b33c048 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index c548b4f0f4..ac978482fc 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_can_esp32_internal_can + canbus_id: esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_can_esp32_internal_can + id: esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..bdc478ea03 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - espnow_temp_sensor + - temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: espnow_temp_sensor + id: temp_sensor - platform: packet_transport provider: test-provider - remote_id: espnow_temp_sensor + remote_id: temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index a7ce7ed280..8b1447a17a 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: fastled_clockless_addr1 + id: addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_clockless_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index 19d00627f8..f6f7c5553b 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: fastled_spi_addr1 + id: addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: fastled_spi_addr1 + id: addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index 59063291e7..c156b4aea1 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: font_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 8ada8b7a4e..387ea47335 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: font_roboto + id: roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: font_sdl_display + id: sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(font_roboto), "Hello, World!"); + it.print(0, 0, id(roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index edf4493aa6..11e2a16ca1 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: graph_ssd1306_display + id: ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 50f8a5bc85..6cee2af232 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,7 +1,6 @@ display: - platform: ssd1306_i2c - i2c_id: i2c_bus - id: graphical_display_menu_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -11,12 +10,12 @@ display: font: - file: "gfonts://Roboto" - id: graphical_display_menu_roboto + id: roboto size: 20 number: - platform: template - id: graphical_display_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -32,13 +31,13 @@ select: switch: - platform: template - id: graphical_display_menu_test_switch + id: test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: graphical_display_menu_ssd1306_i2c_display - font: graphical_display_menu_roboto + display: ssd1306_i2c_display + font: roboto active: false mode: rotary on_enter: @@ -81,7 +80,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: graphical_display_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -98,7 +97,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: graphical_display_menu_test_switch + switch: test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..ff464cda24 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index 9e93c4c289..aea2b4bbb0 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..2e7bfc5ae5 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index ce2a13fca7..03a9c42a38 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: image_main_lcd + id: main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index c912fb9b84..26550d3c5c 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 377bad5578..51d3e19077 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: integration_my_sensor + id: my_sensor pin: VCC - platform: integration - sensor: integration_my_sensor + sensor: my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index a7740e771d..970c18e0d2 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: lcd_menu_test_number + id: test_number min_value: 0 step: 1 max_value: 10 @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: lcd_menu_test_number + number: test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f10..2acc080c6d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b4318..925197182c 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc4..518011e925 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..cb421ed4bb 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: light_test_binary + output: test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad774..a5a37fd559 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: light_test_binary + id: test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 5e71736eb0..18f1ba10ae 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: lilygo_t5_47_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: lilygo_t5_47_ssd1306_i2c_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb..9ba7f34857 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: lock_test_binary + id: test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: lock_test_binary + output: test_binary diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index d99f8ddc4e..93adcf9988 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 12 dc_pin: 13 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index e51240f20d..6a308b67dd 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 cs_pin: 5 dc_pin: 15 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index 0562a4ba51..01b83c4ab8 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -5,7 +5,7 @@ packages: display: spi_id: spi_bus platform: mipi_spi - id: mapping_main_lcd + id: main_lcd model: ili9342 data_rate: 31.25MHz cs_pin: 20 diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index e57c7bec29..9915e086eb 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: monochromatic_light_output_1 + id: light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: monochromatic_light_output_1 + output: light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index f96651e9bf..67a06cf9c1 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: mpr121_bin1 + id: bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: mpr121_bin2 + id: bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: mpr121_bin3 + id: bin3 name: touchkey3 channel: 6 diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 9eadd97a6d..d79e3ee2ed 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -1,6 +1,6 @@ esphome: on_boot: - - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(nextion_main_lcd).is_connected()));' + - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% @@ -272,7 +272,7 @@ text_sensor: display: - platform: nextion - id: nextion_main_lcd + id: main_lcd auto_wake_on_touch: true brightness: 80% command_spacing: 5ms diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml index 70a0809883..190abbc7b1 100644 --- a/tests/components/nextion/common_tft_upload.yaml +++ b/tests/components/nextion/common_tft_upload.yaml @@ -1,5 +1,5 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_url: http://esphome.io/default35.tft tft_upload_http_timeout: 20s tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml index f0b44ce8c3..385fee359e 100644 --- a/tests/components/nextion/common_tft_upload_watchdog.yaml +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -1,3 +1,3 @@ display: - - id: !extend nextion_main_lcd + - id: !extend main_lcd tft_upload_watchdog_timeout: 30s diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 1be2c335bc..79ae7f601d 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: ntc_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: ntc_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: ntc_resist + id: resist - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: ntc_resist + sensor: resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfed..c17c2dd5f8 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: number_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: number_test_number + source_id: test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index ee4c1ed0b8..32c909d351 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index fc61aad92e..d7722d171a 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index 4d2785f3e8..bbb514bded 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9972a673c0..9116fd86e0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index 1f1485fd6c..f219f71ee2 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: online_image_main_lcd + id: main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b..81d802e9bf 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: output_light_output_1 - - output.turn_on: output_light_output_1 + - output.turn_off: light_output_1 + - output.turn_on: light_output_1 - output.set_level: - id: output_light_output_1 + id: light_output_1 level: 50% - output.set_min_power: - id: output_light_output_1 + id: light_output_1 min_power: 20% - output.set_max_power: - id: output_light_output_1 + id: light_output_1 max_power: 80% output: - platform: ${output_platform} - id: output_light_output_1 + id: light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index aeda76d35c..77a77fa3e4 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: pi4ioe5v6408_switch1 + id: switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775f..262e75591e 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: pid_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: pid_template_sensor1 - humidity_sensor: pid_template_sensor1 + sensor: template_sensor1 + humidity_sensor: template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 951d8f7fc5..7ff416dccb 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: prometheus_template_sensor1 + id: template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: prometheus_template_switch1 + id: template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - prometheus_template_sensor1: + template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 0eadfa7392..109db65b63 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: qspi_dbi_main_lcd + id: main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 5631c48f95..c6c7049605 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: remote_transmitter_test_number + id: test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + return {(int32_t)id(test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; + return {(uint8_t)id(test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index 8966b574df..b3eec49548 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: resistance_my_sensor + id: my_sensor pin: ${pin} - platform: resistance - sensor: resistance_my_sensor + sensor: my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resistance_resist + id: resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index bd72abbd17..9f25efa431 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgb_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgb_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -13,6 +13,6 @@ light: - platform: rgb name: RGB Light id: rgb_light - red: rgb_light_output_1 - green: rgb_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 46d8082706..65bb248e95 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbct_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbct_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbct name: RGBCT Light - red: rgbct_light_output_1 - green: rgbct_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 color_temperature: light_output_4 white_brightness: light_output_5 diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index 4a8e56a255..b0f44869d3 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbw_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbw_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -15,8 +15,8 @@ output: light: - platform: rgbw name: RGBW Light - red: rgbw_light_output_1 - green: rgbw_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 white: light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index bb1d73b3bc..0013960c10 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${light_platform} - id: rgbww_light_output_1 + id: light_output_1 pin: ${pin1} - platform: ${light_platform} - id: rgbww_light_output_2 + id: light_output_2 pin: ${pin2} - platform: ${light_platform} id: light_output_3 @@ -18,8 +18,8 @@ output: light: - platform: rgbww name: RGBWW Light - red: rgbww_light_output_1 - green: rgbww_light_output_2 + red: light_output_1 + green: light_output_2 blue: light_output_3 cold_white: light_output_4 warm_white: light_output_5 diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..b9b1436cdb 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: rp2040_pio_led_strip_led_strip + id: led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afb..45c039106f 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: rp2040_pwm_light_output_1 + id: light_output_1 pin: 2 - platform: rp2040_pwm - id: rp2040_pwm_light_output_2 + id: light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 3be86cf8be..d3d3c9ee5e 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_sdl_display + id: sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_sdl_display + sdl_id: sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f..895f4b4b8f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: speaker_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index 70c91259ba..be8172af7e 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: speed_fan_output_1 + id: fan_output_1 pin: ${pin} fan: - platform: speed - id: speed_fan_speed - output: speed_fan_output_1 + id: fan_speed + output: fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f524..f099f77729 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: sprinkler_switch1 + id: switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: sprinkler_switch1 + pump_switch_id: switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index b3b8ad85dc..09eb569a8e 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_ssd1306_i2c_display + id: ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb9..afdf26c150 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: switch_some_binary_sensor + id: some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..659550cc01 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: sx126x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index b7eadc084f..6e48952fcc 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: sx127x_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; + return {0x01, 0x02, (uint8_t)id(my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f1387a7afe..d3985a848b 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -52,7 +52,7 @@ esphome: binary_sensor: - platform: template - id: template_some_binary_sensor + id: some_binary_sensor name: "Garage Door Open" lambda: |- if (id(template_sens).state > 30) { @@ -108,7 +108,7 @@ sensor: name: "Template Sensor" id: template_sens lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(template_some_binary_sensor).state) { + if (id(some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..56089aed1e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c805188005..fa76316b9c 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: uart_test_number + id: test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 6824c5cca8..a40ca455cb 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: udp_my_number + id: my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + return {0x10, 0x20, (uint8_t)id(my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 2365b7a368..4260f0ab4c 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(ufire_ec_test_sensor).state;" + temperature: !lambda "return id(test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: ufire_ec_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: ufire_ec_test_sensor + temperature_sensor: test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index 478c75ad37..f7865ea87b 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: ufire_ise_test_sensor + id: test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: ufire_ise_test_sensor + temperature_sensor: test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index cfba0060d9..b1885af266 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: web_server_idf_test_sensor + id: test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: web_server_idf_test_switch + id: test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 93bb17b38f..39013baeb2 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: wk2132_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 5ff48bc64c..18294974b9 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: wk2132_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 1b2de74c02..49f0d1ec6b 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: wk2168_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index a21a4a34d0..b402077aa3 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: wk2168_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 55c67efd88..863633937b 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: wk2204_i2c_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index ee00da22bb..0b62a7a009 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: wk2204_spi_distance_sensor + id: distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index d48063bb4d..a754bec5c7 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: wk2212_i2c_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index d17db2f676..969f16bb12 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: wk2212_spi_distance_sensor + id: distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py deleted file mode 100644 index 9286380de1..0000000000 --- a/tests/script/test_merge_component_configs.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Unit tests for script/merge_component_configs.py deduplication.""" - -from pathlib import Path -import sys - -import pytest - -# Add the script directory to Python path so we can import the module -sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) - -import merge_component_configs # noqa: E402 - -deduplicate_by_id = merge_component_configs.deduplicate_by_id - - -def test_identical_duplicate_ids_collapse() -> None: - """Two identical items sharing an id collapse to one without error.""" - data = { - "sensor": [ - {"id": "shared", "platform": "template", "name": "A"}, - {"id": "shared", "platform": "template", "name": "A"}, - ] - } - result = deduplicate_by_id(data) - assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] - - -def test_conflicting_duplicate_ids_raise() -> None: - """Two different items sharing an id is a hard error naming the id.""" - data = { - "sensor": [ - {"id": "dup", "platform": "template", "name": "A"}, - {"id": "dup", "platform": "template", "name": "B"}, - ] - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) - - -def test_intentionally_shared_id_does_not_raise() -> None: - """Allowlisted singleton ids may differ across components and collapse.""" - shared = next(iter(merge_component_configs.INTENTIONALLY_SHARED_IDS)) - data = { - "time": [ - {"id": shared, "platform": "sntp"}, - {"id": shared, "platform": "sntp", "servers": ["a"]}, - ] - } - result = deduplicate_by_id(data) - # First occurrence wins, no error raised - assert result["time"] == [{"id": shared, "platform": "sntp"}] - - -def test_items_without_id_are_preserved() -> None: - """Items lacking an id are passed through untouched.""" - data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} - result = deduplicate_by_id(data) - assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] - - -def test_nested_lists_are_checked() -> None: - """Conflicts nested inside dict values are also detected.""" - data = { - "wrapper": { - "sensor": [ - {"id": "dup", "value": 1}, - {"id": "dup", "value": 2}, - ] - } - } - with pytest.raises(ValueError, match="dup"): - deduplicate_by_id(data) From 8aa4157574e6c7dcbbe797e1f51e3f29018f9ed0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:01:29 -0400 Subject: [PATCH 251/282] [fastled_base] Use FastLED IDF component on ESP32 (#16804) --- .clang-tidy.hash | 2 +- esphome/components/fastled_base/__init__.py | 12 ++++-- .../components/fastled_base/fastled_light.h | 2 - esphome/espidf/clang_tidy.py | 6 +++ esphome/idf_component.yml | 5 +++ platformio.ini | 3 +- script/clang-tidy | 12 +++++- tests/unit_tests/test_espidf_clang_tidy.py | 39 +++++++++++++++++++ 8 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/test_espidf_clang_tidy.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 0782b065f3..3bcf356f86 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0b8325f52fca9224efb80dacca51ccbc8b3499bde7bb4aaa6f28a848c2e0a6a8 +d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index c944e8a930..d99dffdc08 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -41,10 +41,16 @@ async def new_fastled_light(config): if CONF_MAX_REFRESH_RATE in config: cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) - cg.add_library("fastled/FastLED", "3.9.16") if CORE.is_esp32: - from esphome.components.esp32 import include_builtin_idf_component + from esphome.components.esp32 import add_idf_component - include_builtin_idf_component("esp_lcd") + add_idf_component( + name="fastled/FastLED", + repo="https://github.com/FastLED/FastLED.git", + ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", + ) + cg.add_library("SPI", None) + else: + cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) return var diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 8e87f67e6d..f8535eb628 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -143,7 +143,6 @@ class FastLEDLightOutput : public light::AddressableLight { } } -#ifdef FASTLED_HAS_CLOCKLESS template class CHIPSET, uint8_t DATA_PIN, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; @@ -160,7 +159,6 @@ class FastLEDLightOutput : public light::AddressableLight { static CHIPSET controller; return add_leds(&controller, num_leds); } -#endif template class CHIPSET, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 2cfbe67a70..7647db63f5 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -160,6 +160,12 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32" CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework + # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at + # reconfigure time). Set here -- before the manifest is written/reconfigured. + os.environ["ESPHOME_ARDUINO"] = ( + "1" if settings.target_framework == "arduino" else "0" + ) + # Special IDF "components" that are tools/subprojects, not requirable by an app # (they provide no public includes and break requirement resolution), plus our diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3a5b050072..4a4bc18579 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -103,3 +103,8 @@ dependencies: version: 0.6.1 lvgl/lvgl: version: 9.5.0 + fastled/FastLED: + git: https://github.com/FastLED/FastLED.git + version: d44c800a9e876a8394caefc2ce4915dd96dac77b + rules: + - if: "$ESPHOME_ARDUINO == 1" diff --git a/platformio.ini b/platformio.ini index 07e9b8aad3..d7bcc49758 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,6 @@ lib_deps = SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt - fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea tonia/HeatpumpIR@1.0.41 ; heatpumpir @@ -108,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) @@ -198,6 +198,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base diff --git a/script/clang-tidy b/script/clang-tidy index 633b8d4b7d..f19bdb9b56 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -35,9 +35,19 @@ def clang_options(idedata): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] if triplet.startswith("xtensa-"): - # clang doesn't support Xtensa (yet?), so compile in 32-bit mode and pretend we're the Xtensa compiler + # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF + # toolchain headers (xtruntime, xtensa/config) need the GCC core config + # (XCHAL_*) it doesn't ship, so we still compile in 32-bit x86 mode and + # just pretend to be Xtensa. Undefine the host x86 arch macros -m32 sets, + # so libraries with x86 SIMD paths (FastLED's fl/math/simd, simd_x86.hpp) + # fall back to their scalar implementation instead of an incomplete + # host-x86 one, and define the xtensa endianness macro newlib's + # machine/ieeefp.h then needs in their place. cmd.append("-m32") + cmd.append("-U__i386__") + cmd.append("-U__x86_64__") cmd.append("-D__XTENSA__") + cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py new file mode 100644 index 0000000000..7a71dc26f4 --- /dev/null +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -0,0 +1,39 @@ +"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" + +import os +from pathlib import Path + +import pytest + +from esphome.espidf.clang_tidy import _Settings, _setup_core + + +def _settings(target_framework: str) -> _Settings: + return _Settings( + idf_target="esp32", + variant="ESP32", + idf_version="5.5.4", + target_framework=target_framework, + platform_defines=("USE_ESP32",), + framework_deps={}, + ) + + +@pytest.mark.parametrize( + ("target_framework", "expected"), + [("arduino", "1"), ("espidf", "0")], +) +def test_setup_core_sets_arduino_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + target_framework: str, + expected: str, +) -> None: + """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + # monkeypatch snapshots os.environ, so the env var _setup_core writes is + # restored after the test instead of leaking into later tests. + monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + + _setup_core(tmp_path / "proj", _settings(target_framework)) + + assert os.environ["ESPHOME_ARDUINO"] == expected From 6996b7ed1c2d6aa6abf639b705af0af928a9361c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:03:08 -0400 Subject: [PATCH 252/282] [ci] Add ESP32 Variants clang-tidy run (S3/P4/C6) (#16825) --- .clang-tidy.hash | 2 +- .github/workflows/ci.yml | 88 ++++++++++++++++++++++ .gitignore | 1 + esphome/core/defines.h | 2 + esphome/espidf/clang_tidy.py | 11 ++- platformio.ini | 11 +++ script/clang_tidy_hash.py | 11 +-- sdkconfig.defaults | 5 -- sdkconfig.defaults.esp32c6 | 14 ++++ sdkconfig.defaults.esp32p4 | 31 ++++++++ sdkconfig.defaults.esp32s3 | 12 +++ tests/script/test_clang_tidy_hash.py | 24 ++++++ tests/unit_tests/test_espidf_clang_tidy.py | 41 ++++++++-- 13 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 sdkconfig.defaults.esp32c6 create mode 100644 sdkconfig.defaults.esp32p4 create mode 100644 sdkconfig.defaults.esp32s3 diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3bcf356f86..e89b4230ad 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d583091c0f465aed86a825138e309af6d9db6834106ab424f36712424a6c2223 +d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40267240d8..a0d604f248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -722,6 +722,93 @@ jobs: run: script/ci-suggest-changes if: always() + clang-tidy-esp32-variants: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.clang-tidy == 'true' + env: + GH_TOKEN: ${{ github.token }} + # The variant tidy envs install ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - id: clang-tidy + name: Run script/clang-tidy for ESP32 S3 + options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 P4 + # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, + # so their code paths differ -- lint them under the P4 build too. + # yamllint disable-line rule:line-length + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + - id: clang-tidy + name: Run script/clang-tidy for ESP32 C6 + # yamllint disable-line rule:line-length + options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + + steps: + - name: Check out code from GitHub + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Need history for HEAD~1 to work for checking changed files + fetch-depth: 2 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache ESP-IDF install + # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). + uses: ./.github/actions/cache-esp-idf + + - name: Register problem matchers + run: | + echo "::add-matcher::.github/workflows/matchers/gcc.json" + echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" + + - name: Check if full clang-tidy scan needed + id: check_full_scan + run: | + . venv/bin/activate + # determine-jobs.clang-tidy-full-scan is true when core C++ changed + # OR the ci-run-all label forced --force-all. Independent of the + # hash check, both must produce a full scan in the job itself. + if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT + elif python script/clang_tidy_hash.py --check; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=hash_changed" >> $GITHUB_OUTPUT + else + echo "full_scan=false" >> $GITHUB_OUTPUT + echo "reason=normal" >> $GITHUB_OUTPUT + fi + + - name: Run clang-tidy + # Limited variant scan: only the files carrying that variant's code paths + # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree). + run: | + . venv/bin/activate + if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + script/clang-tidy --fix ${{ matrix.options }} + else + echo "Running clang-tidy on changed files only" + script/clang-tidy --fix --changed ${{ matrix.options }} + fi + + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + test-build-components-split: name: Test components batch (${{ matrix.components }}) runs-on: ubuntu-24.04 @@ -1273,6 +1360,7 @@ jobs: - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split + - clang-tidy-esp32-variants - determine-jobs - device-builder - test-build-components-split diff --git a/.gitignore b/.gitignore index 4a4a88fd48..de3e4fa68e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ tests/.esphome/ sdkconfig.* !sdkconfig.defaults +!sdkconfig.defaults.* .tests/ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6c840f56ee..410858f904 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -64,6 +64,7 @@ #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED +#define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN @@ -312,6 +313,7 @@ #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 +#define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #ifdef USE_ARDUINO diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 7647db63f5..62d6f0d00d 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -339,11 +339,18 @@ def _write_tidy_project( # ESPHome's static-analysis sdkconfig (repo root): enables the flags any # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components # register and expose their includes. IDF reads ``sdkconfig.defaults`` from - # the project root. + # the project root, plus a per-target ``sdkconfig.defaults.`` + # for variant-only components (e.g. openthread on c6/h2). + repo_root = esphome_dir.parent (work_dir / "sdkconfig.defaults").write_text( - (esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"), + (repo_root / "sdkconfig.defaults").read_text(encoding="utf-8"), encoding="utf-8", ) + target_defaults = repo_root / f"sdkconfig.defaults.{settings.idf_target}" + if target_defaults.is_file(): + (work_dir / target_defaults.name).write_text( + target_defaults.read_text(encoding="utf-8"), encoding="utf-8" + ) def _generate_compile_commands( diff --git a/platformio.ini b/platformio.ini index d7bcc49758..d3fde193b4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -392,6 +392,17 @@ build_flags = ${flags:runtime.build_flags} -DUSE_ESP32_VARIANT_ESP32P4 +[env:esp32p4-idf-tidy] +extends = common:esp32-idf +board = esp32-p4-evboard +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf-tidy +build_flags = + ${common:esp32-idf.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_ESP32_VARIANT_ESP32P4 +build_unflags = + ${common.build_unflags} + ;;;;;;;; ESP32-S2 ;;;;;;;; [env:esp32s2-arduino] diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 1a6e4eb7be..62f76246b4 100755 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -99,11 +99,12 @@ def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: platformio_content = read_file_bytes(platformio_path) hasher.update(platformio_content) - # Hash sdkconfig.defaults file - sdkconfig_path = repo_root / "sdkconfig.defaults" - if sdkconfig_path.exists(): - sdkconfig_content = read_file_bytes(sdkconfig_path) - hasher.update(sdkconfig_content) + # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: + # the per-target files flip CONFIG flags that change which variant code + # paths clang-tidy sees. Include the filename so a rename is detected. + for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): + hasher.update(sdkconfig_path.name.encode()) + hasher.update(read_file_bytes(sdkconfig_path)) # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF # build's include set, which clang-tidy analyzes. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b277ed18d0..8d177a7e26 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -15,8 +15,3 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_SPIRAM=y - -# zigbee -CONFIG_ZB_ENABLED=y -CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 new file mode 100644 index 0000000000..6dd5f4f329 --- /dev/null +++ b/sdkconfig.defaults.esp32c6 @@ -0,0 +1,14 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32c6 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# openthread (only the C6/H2 variants have the 802.15.4 radio) +CONFIG_IEEE802154_ENABLED=y +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_RADIO_NATIVE=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/sdkconfig.defaults.esp32p4 b/sdkconfig.defaults.esp32p4 new file mode 100644 index 0000000000..b49dcf0ef2 --- /dev/null +++ b/sdkconfig.defaults.esp32p4 @@ -0,0 +1,31 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32p4 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# esp32_hosted (P4 has no native Wi-Fi; it drives a co-processor over SDIO/SPI). +# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 slave) so the esp_hosted +# code paths compile under static analysis. +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_SDIO_SLOT_1=y +CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y +CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS=y +CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000 +CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y +CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=54 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17 + +# BLE runs over the hosted co-processor on P4 (no native BT controller), so +# esp32_ble_tracker must take the hosted bluedroid path instead of . +CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID=y + +# tinyusb CDC (usb_cdc_acm), same as esp32s3 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..15b97eb1b4 --- /dev/null +++ b/sdkconfig.defaults.esp32s3 @@ -0,0 +1,12 @@ +# Per-target ESP-IDF sdkconfig defaults for esp32s3 static analysis (clang-tidy) only. +# Read by IDF in addition to sdkconfig.defaults. Enables variant-only components so +# their headers register for the tidy translation unit (these are normally set at +# codegen via add_idf_sdkconfig_option, which the stub tidy build skips). + +# tinyusb CDC (usb_cdc_acm) -- the esp_tinyusb managed component is already in +# esphome/idf_component.yml; these enable its CDC class so tud_cdc_* and the +# CONFIG_TINYUSB_CDC_* macros are declared. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index e19e7886a2..194926a5df 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -63,6 +63,7 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: expected_hasher.update(clang_tidy_content) expected_hasher.update(requirements_version.encode()) expected_hasher.update(platformio_content) + expected_hasher.update(b"sdkconfig.defaults") expected_hasher.update(sdkconfig_content) expected_hash = expected_hasher.hexdigest() @@ -71,6 +72,29 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: assert result == expected_hash +def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( + tmp_path: Path, +) -> None: + """Per-target sdkconfig.defaults. files must be part of the hash.""" + (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") + (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") + (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") + (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") + + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + # Adding a per-target file must change the hash. + per_target = tmp_path / "sdkconfig.defaults.esp32c6" + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=y\n") + after_add = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_add != before + + # Editing the per-target file must change the hash again. + per_target.write_bytes(b"CONFIG_OPENTHREAD_ENABLED=n\n") + after_edit = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + assert after_edit != after_add + + def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: """Test calculating hash without sdkconfig.defaults file.""" clang_tidy_content = b"Checks: '-*,readability-*'\n" diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 7a71dc26f4..9791dfc543 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -1,24 +1,51 @@ -"""Tests for esphome.espidf.clang_tidy tidy-project setup.""" +"""Tests for esphome.espidf.clang_tidy tidy-project generation.""" import os from pathlib import Path import pytest -from esphome.espidf.clang_tidy import _Settings, _setup_core +from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project + +REPO_ROOT = Path(__file__).resolve().parents[2] -def _settings(target_framework: str) -> _Settings: +def _settings(idf_target: str = "esp32", target_framework: str = "espidf") -> _Settings: return _Settings( - idf_target="esp32", - variant="ESP32", + idf_target=idf_target, + variant=idf_target.upper(), idf_version="5.5.4", target_framework=target_framework, - platform_defines=("USE_ESP32",), + platform_defines=( + "USE_ESP32", + f"USE_ESP32_VARIANT_{idf_target.upper()}", + "USE_ESP_IDF", + ), framework_deps={}, ) +def test_write_tidy_project_copies_base_sdkconfig(tmp_path: Path) -> None: + """The shared sdkconfig.defaults is always copied; no per-target file for esp32.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32")) + + assert (tmp_path / "sdkconfig.defaults").is_file() + # esp32 has no sdkconfig.defaults.esp32, so nothing extra is copied. + assert not (tmp_path / "sdkconfig.defaults.esp32").exists() + + +def test_write_tidy_project_copies_per_target_sdkconfig(tmp_path: Path) -> None: + """A repo-root sdkconfig.defaults. is also copied into the build dir.""" + _write_tidy_project(tmp_path, [], {}, _settings("esp32c6")) + + target = tmp_path / "sdkconfig.defaults.esp32c6" + assert (tmp_path / "sdkconfig.defaults").is_file() + assert target.is_file() + assert target.read_text(encoding="utf-8") == ( + REPO_ROOT / "sdkconfig.defaults.esp32c6" + ).read_text(encoding="utf-8") + + @pytest.mark.parametrize( ("target_framework", "expected"), [("arduino", "1"), ("espidf", "0")], @@ -34,6 +61,6 @@ def test_setup_core_sets_arduino_env( # restored after the test instead of leaking into later tests. monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) - _setup_core(tmp_path / "proj", _settings(target_framework)) + _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) assert os.environ["ESPHOME_ARDUINO"] == expected From 745db9f705818a5a1df2893176e98409254f4ee9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:10:08 +1000 Subject: [PATCH 253/282] [motion] Implement hub component for IMUs (#16226) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/motion/__init__.py | 221 +++++ .../components/motion/motion_component.cpp | 194 ++++ esphome/components/motion/motion_component.h | 154 +++ esphome/components/motion/sensor.py | 128 +++ tests/component_tests/motion/__init__.py | 0 tests/component_tests/motion/test_motion.py | 899 ++++++++++++++++++ 7 files changed, 1597 insertions(+) create mode 100644 esphome/components/motion/__init__.py create mode 100644 esphome/components/motion/motion_component.cpp create mode 100644 esphome/components/motion/motion_component.h create mode 100644 esphome/components/motion/sensor.py create mode 100644 tests/component_tests/motion/__init__.py create mode 100644 tests/component_tests/motion/test_motion.py diff --git a/CODEOWNERS b/CODEOWNERS index 3c3e502058..abe33f9467 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -351,6 +351,7 @@ esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt +esphome/components/motion/* @esphome/core esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py new file mode 100644 index 0000000000..aea052fa2f --- /dev/null +++ b/esphome/components/motion/__init__.py @@ -0,0 +1,221 @@ +from collections.abc import Callable +import re + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.helpers import fnv1_hash_object_id + +CODEOWNERS = ["@esphome/core"] + +DOMAIN = "motion" +IS_PLATFORM_COMPONENT = True + +# C++ namespace / class +motion_ns = cg.esphome_ns.namespace("motion") +MotionComponent = motion_ns.class_("MotionComponent", cg.PollingComponent) + +AXES = ["x", "y", "z"] + +CONF_AXIS_MAP = "axis_map" +CONF_MOTION_ID = "motion_id" +CONF_TRANSFORM_MATRIX = "transform_matrix" + +CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) +CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) +ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) + +KEY_ACCELEROMETER = "accelerometer" +KEY_GYROSCOPE = "gyroscope" + +SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + } +) + +_AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE) + + +def _axis_map(config: dict) -> dict: + errors = [] + for key, axis in config.items(): + if _AXIS_REGEX.fullmatch(axis) is None: + errors.append( + cv.Invalid( + "Each 'axis_map' config value must be one of 'x', 'y' or 'z' (optionally preceded by '+' or '-').", + path=[key], + ) + ) + values = {x.lower().removeprefix("-").removeprefix("+") for x in config.values()} + if values != set(AXES): + errors.append(cv.Invalid("Each axis may be mapped only once")) + if errors: + raise cv.MultipleInvalid(errors) + return config + + +def _axis_map_to_matrix(config: dict[str, str]) -> list[float]: + matrix = [] + for target_axis in AXES: + source_axis = config[target_axis].lower() + sign = -1.0 if source_axis.startswith("-") else 1.0 + source_axis = source_axis.removeprefix("+").removeprefix("-") + + row = [0.0, 0.0, 0.0] + row[AXES.index(source_axis)] = sign + matrix.extend(row) + + return matrix + + +def _transform_matrix(value): + """Accept a flat list of 9 floats or a 3x3 nested list.""" + if not isinstance(value, list) or len(value) == 0: + raise cv.Invalid("Expected a list of 9 numbers or a 3x3 nested list") + # Nested 3x3 + if isinstance(value[0], list): + if len(value) != 3: + raise cv.Invalid(f"3x3 matrix must have 3 rows, got {len(value)}") + flat = [] + for i, row in enumerate(value): + if not isinstance(row, list) or len(row) != 3: + raise cv.Invalid("Each row must be a list of 3 numbers", path=[i]) + flat.extend(cv.float_(v) for v in row) + return flat + # Flat list + if len(value) != 9: + raise cv.Invalid(f"Flat matrix must have exactly 9 values, got {len(value)}") + return [cv.float_(v) for v in value] + + +def _validate_matrix_options(config): + if CONF_AXIS_MAP in config and CONF_TRANSFORM_MATRIX in config: + raise cv.Invalid( + f"'{CONF_AXIS_MAP}' and '{CONF_TRANSFORM_MATRIX}' are mutually exclusive" + ) + return config + + +# Top-level CONFIG_SCHEMA +_CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Optional(CONF_AXIS_MAP): cv.All( + {cv.Required(k): cv.string_strict for k in AXES}, + _axis_map, + ), + cv.Optional(CONF_TRANSFORM_MATRIX): _transform_matrix, + } + ) + .extend(cv.polling_component_schema("250ms")) + .add_extra(_validate_matrix_options) +) + + +def _add_data(has_accel: bool, has_gyro: bool) -> Callable[[dict], dict]: + + def validator(config): + config = config.copy() + config[KEY_ACCELEROMETER] = has_accel + config[KEY_GYROSCOPE] = has_gyro + return config + + return validator + + +def motion_schema(class_: MockObjClass, has_accel: bool, has_gyro: bool) -> cv.Schema: + return _CONFIG_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ).add_extra(_add_data(has_accel, has_gyro)) + + +# Code generation +async def register_motion_component(var: MockObj, config) -> None: + await cg.register_component(var, config) + # Set preference key for NVS save/restore (based on component ID) + obj_id = config[CONF_ID].id + pref_hash = fnv1_hash_object_id(obj_id) + cg.add(var.set_calibration_key(pref_hash)) + if axis_map := config.get(CONF_AXIS_MAP): + cg.add(var.set_matrix(_axis_map_to_matrix(axis_map))) + elif transform_matrix := config.get(CONF_TRANSFORM_MATRIX): + cg.add(var.set_matrix(transform_matrix)) + + +async def new_motion_component(config: dict) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await register_motion_component(var, config) + return var + + +# --- Actions --- + +CONF_SAVE = "save" + +CALIBRATE_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), + } +) + + +async def _build_calibrate_action(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + if on_success := config.get(CONF_ON_SUCCESS): + await automation.build_automation(var.get_success_trigger(), [], on_success) + if on_error := config.get(CONF_ON_ERROR): + await automation.build_automation(var.get_error_trigger(), [], on_error) + return var + + +@automation.register_action( + "motion.calibrate_level", + CalibrateLevelAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_level_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +@automation.register_action( + "motion.calibrate_heading", + CalibrateHeadingAction, + CALIBRATE_ACTION_SCHEMA, + synchronous=True, +) +async def calibrate_heading_to_code(config, action_id, template_arg, args): + return await _build_calibrate_action(config, action_id, template_arg, args) + + +CLEAR_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(MotionComponent), + cv.Optional(CONF_SAVE, default=False): cv.boolean, + } +) + + +@automation.register_action( + "motion.clear_calibration", + ClearCalibrationAction, + CLEAR_ACTION_SCHEMA, + synchronous=True, +) +async def clear_calibration_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + if config.get(CONF_SAVE): + cg.add(var.set_save(True)) + return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp new file mode 100644 index 0000000000..8715c8385c --- /dev/null +++ b/esphome/components/motion/motion_component.cpp @@ -0,0 +1,194 @@ +#include "motion_component.h" +#include "esphome/core/log.h" + +namespace esphome::motion { + +static const char *const TAG = "motion"; + +static void log_matrix(const float m[9]) { + ESP_LOGCONFIG(TAG, " Calibration matrix:"); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[0], m[1], m[2]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[3], m[4], m[5]); + ESP_LOGCONFIG(TAG, " - [%9.6f, %9.6f, %9.6f]", m[6], m[7], m[8]); +} + +// FNV-1a over the raw bytes of the matrix. Identical axis maps always yield +// bit-identical matrices, so this is a stable fingerprint of the build-time base. +static uint32_t hash_matrix(const float m[9]) { + const uint8_t *bytes = reinterpret_cast(m); + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < sizeof(float) * 9; i++) { + hash ^= bytes[i]; + hash *= 16777619UL; + } + return hash; +} + +void MotionComponent::setup() { + // matrix_ currently holds the build-time base (set_matrix ran during codegen). + this->base_hash_ = hash_matrix(this->base_matrix_); + this->pref_ = global_preferences->make_preference(this->pref_key_); + CalibrationPref saved; + if (this->pref_.load(&saved) && saved.base_hash == this->base_hash_) { + memcpy(this->matrix_, saved.matrix, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Restored calibration from NVS"); + } else { + ESP_LOGD(TAG, "No matching saved calibration; using build-time matrix"); + } + log_matrix(this->matrix_); +} +void MotionComponent::dump_config() { + LOG_UPDATE_INTERVAL(this); + log_matrix(this->matrix_); +} +bool MotionComponent::save_calibration() { + if (this->pref_key_ == 0) { + ESP_LOGW(TAG, "Cannot save calibration: no preference key set"); + return false; + } + CalibrationPref pref{this->base_hash_, {}}; + memcpy(pref.matrix, this->matrix_, sizeof(pref.matrix)); + if (this->pref_.save(&pref)) { + global_preferences->sync(); + ESP_LOGI(TAG, "Saved calibration to NVS"); + return true; + } + ESP_LOGW(TAG, "Calibration save failed"); + return false; +} +void MotionComponent::clear_calibration() { + memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); + ESP_LOGI(TAG, "Calibration reset to build-time matrix"); + log_matrix(this->matrix_); +} +void MotionComponent::update() { + if (this->is_failed()) + return; + MotionData motion_data{}; + MotionData raw_data{}; + if (!this->update_data(raw_data)) + return; + this->map_axes_(motion_data.acceleration, raw_data.acceleration); + this->map_axes_(motion_data.angular_rate, raw_data.angular_rate); + this->motion_data_callback_.call(motion_data); + + ESP_LOGV(TAG, "Accel: [%.3f, %.3f, %.3f] g; Gyro: [%.3f, %.3f, %.3f] °/s", motion_data.acceleration[X_AXIS], + motion_data.acceleration[Y_AXIS], motion_data.acceleration[Z_AXIS], motion_data.angular_rate[X_AXIS], + motion_data.angular_rate[Y_AXIS], motion_data.angular_rate[Z_AXIS]); +} + +bool MotionComponent::calibrate_level() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_level: failed to read sensor data"); + return false; + } + + // Apply the current matrix first so any existing axis mapping is preserved. + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float nx = mapped[X_AXIS]; + float ny = mapped[Y_AXIS]; + float nz = mapped[Z_AXIS]; + float mag = std::sqrt(nx * nx + ny * ny + nz * nz); + if (mag < 0.1f) { + ESP_LOGW(TAG, "calibrate_level: acceleration magnitude too small (%.3f)", mag); + return false; + } + + // Normalize + nx /= mag; + ny /= mag; + nz /= mag; + + // Compute rotation matrix R such that R * [nx, ny, nz] = [0, 0, 1] + // using Rodrigues' rotation formula, then compose with the existing matrix. + if (nz > 0.99999f) { + // Already aligned with +Z — nothing to compose + ESP_LOGI(TAG, "Level calibration: already aligned"); + log_matrix(this->matrix_); + // returning true here will trigger on_success and a save to NVS, but the save will ultimately be a no-op + // since the backend sync will not write unchanged values. + return true; + } + + float r[9]; + if (nz < -0.9999f) { + // Aligned with -Z — 180° rotation about X + float m[9] = {1, 0, 0, 0, -1, 0, 0, 0, -1}; + memcpy(r, m, sizeof(r)); + } else { + float f = 1.0f / (1.0f + nz); + r[0] = 1.0f - nx * nx * f; + r[1] = -nx * ny * f; + r[2] = -nx; + r[3] = -nx * ny * f; + r[4] = 1.0f - ny * ny * f; + r[5] = -ny; + r[6] = nx; + r[7] = ny; + r[8] = nz; + } + + // Compose: new_matrix = R * old_matrix + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + this->matrix_[i * 3 + j] = r[i * 3 + 0] * old[j] + r[i * 3 + 1] * old[3 + j] + r[i * 3 + 2] * old[6 + j]; + } + } + + ESP_LOGI(TAG, "Level calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +bool MotionComponent::calibrate_heading() { + MotionData raw{}; + if (!this->update_data(raw)) { + ESP_LOGW(TAG, "calibrate_heading: failed to read sensor data"); + return false; + } + + // Apply current matrix to get the mapped acceleration + float mapped[3]; + this->map_axes_(mapped, raw.acceleration); + + float mx = mapped[X_AXIS]; + float my = mapped[Y_AXIS]; + float h = std::sqrt(mx * mx + my * my); + if (h < 0.05f) { + ESP_LOGW(TAG, "calibrate_heading: device must be tilted (XY magnitude %.3f too small)", h); + return false; + } + + // Rotation angle in the XY plane: eliminate Y component while preserving X sign. + // Without the sign correction, atan2(my,mx) would rotate everything to +X, + // flipping the sign when the tilt projects onto -X. + float sign_mx = mx >= 0 ? 1.0f : -1.0f; + float cos_phi = sign_mx * mx / h; // = |mx| / h + float sin_phi = sign_mx * my / h; + + // Compose Rz(-phi) with the current matrix + // Rz(-phi) = [[cos_phi, sin_phi, 0], [-sin_phi, cos_phi, 0], [0, 0, 1]] + float old[9]; + memcpy(old, this->matrix_, sizeof(old)); + + this->matrix_[0] = cos_phi * old[0] + sin_phi * old[3]; + this->matrix_[1] = cos_phi * old[1] + sin_phi * old[4]; + this->matrix_[2] = cos_phi * old[2] + sin_phi * old[5]; + this->matrix_[3] = -sin_phi * old[0] + cos_phi * old[3]; + this->matrix_[4] = -sin_phi * old[1] + cos_phi * old[4]; + this->matrix_[5] = -sin_phi * old[2] + cos_phi * old[5]; + // Row 2 unchanged + + ESP_LOGI(TAG, "Heading calibration applied (mapped accel: [%.3f, %.3f, %.3f])", mapped[X_AXIS], mapped[Y_AXIS], + mapped[Z_AXIS]); + log_matrix(this->matrix_); + return true; +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h new file mode 100644 index 0000000000..00310c16fe --- /dev/null +++ b/esphome/components/motion/motion_component.h @@ -0,0 +1,154 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#include +#include +#include // required for generated lambda code + +namespace esphome::motion { + +// ---Data class + +struct MotionData { + float acceleration[3]{NAN, NAN, NAN}; + float angular_rate[3]{NAN, NAN, NAN}; + // TODO - compass +}; + +// indices into data arrays +static constexpr uint8_t X_AXIS = 0; +static constexpr uint8_t Y_AXIS = 1; +static constexpr uint8_t Z_AXIS = 2; + +// Persisted calibration. `base_hash` ties the stored matrix to the build-time +// (axis_map / transform_matrix) base; if the base changes the saved calibration +// is ignored. Stored under a stable, ID-derived key so it overwrites in place. +struct CalibrationPref { + uint32_t base_hash; + float matrix[9]; +} PACKED; + +// Main component class +class MotionComponent : public PollingComponent { + public: + // Lifecycle + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_matrix(const std::array &m) { + memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); + memcpy(this->matrix_, m.data(), sizeof(this->matrix_)); + } + void set_calibration_key(uint32_t key) { this->pref_key_ = key; } + + /// Calibrate the matrix so the current reading maps to [0, 0, 1] (device flat). + bool calibrate_level(); + /// Assuming Y-axis rotation only, correct the heading so X/Y align correctly. + bool calibrate_heading(); + /// Save the current matrix to NVS. + bool save_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. + void clear_calibration(); + + template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } + + protected: + // platforms must implement this method to update raw data. + virtual bool update_data(MotionData &data) = 0; + + // for mapping axes + float matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + // build-time base (axis_map / transform_matrix); used to detect config changes + // and to restore on clear_calibration(). + float base_matrix_[9]{ + 1, 0, 0, 0, 1, 0, 0, 0, 1, + }; + + void map_axes_(float output[3], const float input[3]) const { + output[0] = input[X_AXIS] * this->matrix_[0] + input[Y_AXIS] * this->matrix_[1] + input[Z_AXIS] * this->matrix_[2]; + output[1] = input[X_AXIS] * this->matrix_[3] + input[Y_AXIS] * this->matrix_[4] + input[Z_AXIS] * this->matrix_[5]; + output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8]; + } + + LazyCallbackManager motion_data_callback_{}; + uint32_t pref_key_{0}; + uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() + ESPPreferenceObject pref_{}; +}; + +// --- Actions --- + +template class CalibrateLevelAction : public Action { + public: + explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_level()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class CalibrateHeadingAction : public Action { + public: + explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + Trigger<> *get_success_trigger() { return &this->success_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } + + protected: + void play(const Ts &...) override { + if (this->parent_->calibrate_heading()) { + // if not saving, calibration success is enough. If save required only report success after that succeeds too. + if (!this->save_ || this->parent_->save_calibration()) { + this->success_trigger_.trigger(); + return; + } + } + this->error_trigger_.trigger(); + } + + MotionComponent *parent_; + Trigger<> success_trigger_; + Trigger<> error_trigger_; + bool save_{false}; +}; + +template class ClearCalibrationAction : public Action { + public: + explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} + void set_save(bool save) { this->save_ = save; } + + protected: + void play(const Ts &...) override { + this->parent_->clear_calibration(); + if (this->save_) + this->parent_->save_calibration(); + } + + MotionComponent *parent_; + bool save_{false}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/sensor.py b/esphome/components/motion/sensor.py new file mode 100644 index 0000000000..ad3163a01a --- /dev/null +++ b/esphome/components/motion/sensor.py @@ -0,0 +1,128 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TYPE, + ICON_ACCELERATION, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREE_PER_SECOND, + UNIT_DEGREES, + UNIT_G, +) +from esphome.cpp_generator import MockObj +from esphome.cpp_types import std_ns +import esphome.final_validate as fv + +from . import ( + AXES, + CONF_MOTION_ID, + KEY_ACCELEROMETER, + KEY_GYROSCOPE, + SENSOR_SCHEMA, + motion_ns, +) + +MotionData = motion_ns.class_("MotionData") + +CONF_PITCH = "pitch" +CONF_ROLL = "roll" +ICON_SEESAW = "mdi:seesaw" + + +def _accel_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_G, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _gyro_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREE_PER_SECOND, + icon=ICON_ROTATE_RIGHT, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +def _level_sensor_schema(): + return sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SEESAW, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(SENSOR_SCHEMA) + + +_ACCELERATIONS = ["acceleration_" + a for a in AXES] +_GYROSCOPES = ["gyroscope_" + g for g in AXES] +_ANGULAR_RATES = ["angular_rate_" + r for r in AXES] + +CONFIG_SCHEMA = cv.typed_schema( + { + **{x: _accel_sensor_schema() for x in _ACCELERATIONS}, + **{x: _gyro_sensor_schema() for x in _GYROSCOPES}, + **{x: _gyro_sensor_schema() for x in _ANGULAR_RATES}, + **{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)}, + } +) + + +def _final_validate(config: dict) -> None: + full_config = fv.full_config.get() + motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1] + motion_config = full_config.get_config_for_path(motion_path) + has_accel = motion_config.get(KEY_ACCELEROMETER, False) + has_gyro = motion_config.get(KEY_GYROSCOPE, False) + + sensor_type = config[CONF_TYPE] + if ( + sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH) + ) and not has_accel: + raise cv.Invalid( + "The motion device does not measure acceleration", path=[CONF_TYPE] + ) + if (sensor_type in _GYROSCOPES or sensor_type in _ANGULAR_RATES) and not has_gyro: + raise cv.Invalid( + "The motion device does not measure angular rate", path=[CONF_TYPE] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: + """Build the C++ expression for a motion sensor type.""" + + # Note that is included via this component's header file. + pif = std_ns.namespace("numbers").pi_v.template(cg.float_) + if sensor_type == CONF_ROLL: + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(ay, az) * (180.0 / pif) + if sensor_type == CONF_PITCH: + ax = data.acceleration[0] + ay = data.acceleration[1] + az = data.acceleration[2] + return std_ns.atan2(-ax, std_ns.sqrt(ay * ay + az * az)) * (180.0 / pif) + sensor_offset = AXES.index(sensor_type[-1:]) + if sensor_type in _GYROSCOPES: + sensor_type = _ANGULAR_RATES[sensor_offset] + return getattr(data, str(sensor_type[:-2]))[sensor_offset] + + +async def to_code(config): + sensor_type = config[CONF_TYPE] + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_MOTION_ID]) + data = MockObj("data") + expr = build_sensor_expr(sensor_type, data) + value_lambda = await cg.process_lambda( + var.publish_state(expr), + [(MotionData.operator("ref"), str(data))], + ) + cg.add(parent.add_listener(value_lambda)) diff --git a/tests/component_tests/motion/__init__.py b/tests/component_tests/motion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py new file mode 100644 index 0000000000..f2c0f26344 --- /dev/null +++ b/tests/component_tests/motion/test_motion.py @@ -0,0 +1,899 @@ +"""Tests for the motion component.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.motion import ( + CALIBRATE_ACTION_SCHEMA, + CLEAR_ACTION_SCHEMA, + CONF_AXIS_MAP, + CONF_SAVE, + CONF_TRANSFORM_MATRIX, + _axis_map, + _axis_map_to_matrix, + _build_calibrate_action, + _transform_matrix, + _validate_matrix_options, + clear_calibration_to_code, +) +from esphome.components.motion.sensor import ( + _ACCELERATIONS, + _ANGULAR_RATES, + _GYROSCOPES, + CONF_PITCH, + CONF_ROLL, + CONFIG_SCHEMA, + build_sensor_expr, +) +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.cpp_generator import MockObj + +# --- Axis map validation --- + + +class TestAxisMapValidation: + """Tests for the _axis_map validator.""" + + def test_identity_map(self): + result = _axis_map({"x": "x", "y": "y", "z": "z"}) + assert result == {"x": "x", "y": "y", "z": "z"} + + def test_axis_swap(self): + result = _axis_map({"x": "y", "y": "z", "z": "x"}) + assert result == {"x": "y", "y": "z", "z": "x"} + + def test_negation(self): + result = _axis_map({"x": "-y", "y": "z", "z": "x"}) + assert result == {"x": "-y", "y": "z", "z": "x"} + + def test_plus_prefix(self): + result = _axis_map({"x": "+y", "y": "z", "z": "x"}) + assert result == {"x": "+y", "y": "z", "z": "x"} + + def test_case_insensitive(self): + result = _axis_map({"x": "X", "y": "Y", "z": "Z"}) + assert result == {"x": "X", "y": "Y", "z": "Z"} + + def test_invalid_axis_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "a", "y": "y", "z": "z"}) + + def test_duplicate_mapping(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "z"}) + + def test_all_same_axis(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "x", "y": "x", "z": "x"}) + + def test_empty_value(self): + with pytest.raises(MultipleInvalid): + _axis_map({"x": "", "y": "y", "z": "z"}) + + def test_invalid_and_duplicate(self): + """Both invalid value and duplicate should produce multiple errors.""" + with pytest.raises(MultipleInvalid) as exc_info: + _axis_map({"x": "a", "y": "x", "z": "z"}) + # Should have at least the invalid regex error and the duplicate error + assert len(exc_info.value.errors) >= 2 + + +# --- Transform matrix validation --- + + +class TestTransformMatrix: + """Tests for the _transform_matrix validator.""" + + def test_flat_identity(self): + result = _transform_matrix([1, 0, 0, 0, 1, 0, 0, 0, 1]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_flat_values_converted_to_float(self): + result = _transform_matrix([1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert all(isinstance(v, float) for v in result) + + def test_nested_3x3(self): + result = _transform_matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + assert result == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + def test_nested_3x3_values(self): + result = _transform_matrix( + [[0.5, 0.1, -0.2], [-0.1, 0.9, 0.3], [0.2, -0.3, 0.8]] + ) + assert len(result) == 9 + assert result[0] == pytest.approx(0.5) + assert result[3] == pytest.approx(-0.1) + assert result[8] == pytest.approx(0.8) + + def test_flat_wrong_length_short(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1, 0, 0]) + + def test_flat_wrong_length_long(self): + with pytest.raises(Invalid, match="exactly 9"): + _transform_matrix([1] * 12) + + def test_nested_wrong_row_count(self): + with pytest.raises(Invalid, match="3 rows"): + _transform_matrix([[1, 0, 0], [0, 1, 0]]) + + def test_nested_wrong_column_count(self): + with pytest.raises(Invalid, match="3 numbers"): + _transform_matrix([[1, 0], [0, 1, 0], [0, 0, 1]]) + + def test_empty_list(self): + with pytest.raises(Invalid): + _transform_matrix([]) + + def test_not_a_list(self): + with pytest.raises(Invalid): + _transform_matrix("identity") + + +class TestValidateMatrixOptions: + """Tests for mutual exclusivity of axis_map and transform_matrix.""" + + def test_neither_passes(self): + config = {"some_key": "value"} + assert _validate_matrix_options(config) is config + + def test_axis_map_only_passes(self): + config = {CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}} + assert _validate_matrix_options(config) is config + + def test_transform_matrix_only_passes(self): + config = {CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1]} + assert _validate_matrix_options(config) is config + + def test_both_raises(self): + config = { + CONF_AXIS_MAP: {"x": "x", "y": "y", "z": "z"}, + CONF_TRANSFORM_MATRIX: [1, 0, 0, 0, 1, 0, 0, 0, 1], + } + with pytest.raises(Invalid, match="mutually exclusive"): + _validate_matrix_options(config) + + +# --- Axis map to matrix --- + + +class TestAxisMapToMatrix: + """Tests for _axis_map_to_matrix conversion.""" + + def test_identity(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_swap_xy(self): + # x←y, y←x, z←z + assert _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) == [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + ] + + def test_rotate_xyz(self): + # x←y, y←z, z←x + assert _axis_map_to_matrix({"x": "y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_negate_x(self): + assert _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) == [ + -1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + ] + + def test_negate_z(self): + assert _axis_map_to_matrix({"x": "x", "y": "y", "z": "-z"}) == [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + -1, + ] + + def test_swap_and_negate(self): + # x←-y, y←z, z←x + assert _axis_map_to_matrix({"x": "-y", "y": "z", "z": "x"}) == [ + 0, + -1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + def test_plus_prefix_ignored(self): + assert _axis_map_to_matrix({"x": "+y", "y": "z", "z": "x"}) == [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + ] + + +# --- Sensor expression generation --- + + +def _expr_str(sensor_type: str) -> str: + """Build a sensor expression via the production function and return its string form.""" + return str(build_sensor_expr(sensor_type, MockObj("data"))) + + +class TestSensorExpressions: + """Tests that sensor code generation produces correct C++ expressions.""" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("acceleration_x", 0), + ("acceleration_y", 1), + ("acceleration_z", 2), + ], + ) + def test_acceleration_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.acceleration[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("angular_rate_x", 0), + ("angular_rate_y", 1), + ("angular_rate_z", 2), + ], + ) + def test_angular_rate_sensors(self, sensor_type, expected_index): + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + @pytest.mark.parametrize( + "sensor_type,expected_index", + [ + ("gyroscope_x", 0), + ("gyroscope_y", 1), + ("gyroscope_z", 2), + ], + ) + def test_gyroscope_maps_to_angular_rate(self, sensor_type, expected_index): + """Gyroscope sensor types should be remapped to angular_rate in the expression.""" + assert _expr_str(sensor_type) == f"data.angular_rate[{expected_index}]" + + def test_roll_expression(self): + expr = _expr_str("roll") + assert "std::atan2" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Roll should NOT reference acceleration[0] + assert "data.acceleration[0]" not in expr + + def test_pitch_expression(self): + expr = _expr_str("pitch") + assert "std::atan2" in expr + assert "std::sqrt" in expr + # All three axes used + assert "data.acceleration[0]" in expr + assert "data.acceleration[1]" in expr + assert "data.acceleration[2]" in expr + assert "180.0f" in expr + assert "std::numbers::pi_v" in expr + # Pitch negates the x component + assert "(-data.acceleration[0])" in expr + + +# --- Calibration math --- +# +# Pure-Python reimplementation of the C++ calibration algorithms so we can +# verify the mathematical properties without needing to compile C++. + + +def _mat_vec(m: list[float], v: list[float]) -> list[float]: + """Multiply a row-major 3x3 matrix by a 3-vector.""" + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ] + + +def _mat_mul(a: list[float], b: list[float]) -> list[float]: + """Multiply two row-major 3x3 matrices.""" + r = [0.0] * 9 + for i in range(3): + for j in range(3): + r[i * 3 + j] = sum(a[i * 3 + k] * b[k * 3 + j] for k in range(3)) + return r + + +def _transpose(m: list[float]) -> list[float]: + """Transpose a row-major 3x3 matrix.""" + return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]] + + +def _det(m: list[float]) -> float: + """Determinant of a 3x3 matrix.""" + return ( + m[0] * (m[4] * m[8] - m[5] * m[7]) + - m[1] * (m[3] * m[8] - m[5] * m[6]) + + m[2] * (m[3] * m[7] - m[4] * m[6]) + ) + + +def _calibrate_level( + raw: list[float], matrix: list[float] | None = None +) -> list[float]: + """Python port of MotionComponent::calibrate_level. + + Composes the correction with *matrix* (defaults to identity). + """ + import math + + if matrix is None: + matrix = list(IDENTITY) + + # Apply current matrix first + mapped = _mat_vec(matrix, raw) + + nx, ny, nz = mapped + mag = math.sqrt(nx * nx + ny * ny + nz * nz) + nx /= mag + ny /= mag + nz /= mag + + if nz > 0.9999: + return matrix[:] # already aligned, preserve existing matrix + + if nz < -0.9999: + r = [1, 0, 0, 0, -1, 0, 0, 0, -1] + else: + f = 1.0 / (1.0 + nz) + r = [ + 1.0 - nx * nx * f, + -nx * ny * f, + -nx, + -nx * ny * f, + 1.0 - ny * ny * f, + -ny, + nx, + ny, + nz, + ] + + return _mat_mul(r, matrix) + + +def _calibrate_heading(matrix: list[float], raw: list[float]) -> list[float]: + """Python port of MotionComponent::calibrate_heading.""" + import math + + mapped = _mat_vec(matrix, raw) + mx, my = mapped[0], mapped[1] + h = math.sqrt(mx * mx + my * my) + sign_mx = 1.0 if mx >= 0 else -1.0 + cos_phi = sign_mx * mx / h # = |mx| / h + sin_phi = sign_mx * my / h + + old = matrix[:] + new = old[:] + new[0] = cos_phi * old[0] + sin_phi * old[3] + new[1] = cos_phi * old[1] + sin_phi * old[4] + new[2] = cos_phi * old[2] + sin_phi * old[5] + new[3] = -sin_phi * old[0] + cos_phi * old[3] + new[4] = -sin_phi * old[1] + cos_phi * old[4] + new[5] = -sin_phi * old[2] + cos_phi * old[5] + return new + + +IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1] + + +class TestCalibrateLevel: + """Verify the Rodrigues-based level calibration matrix.""" + + def _assert_maps_to_z(self, raw: list[float]) -> list[float]: + """Assert that the calibration matrix maps raw to [0, 0, 1].""" + import math + + m = _calibrate_level(raw) + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-6) + assert result[1] == pytest.approx(0, abs=1e-6) + assert result[2] == pytest.approx(1, abs=1e-6) + return m + + def test_already_flat(self): + m = _calibrate_level([0, 0, 1.0]) + assert m == IDENTITY + + def test_preserves_existing_matrix_when_flat(self): + """If already flat after axis mapping, level cal should not change the matrix.""" + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + m = _calibrate_level([0, 0, 1.0], swap) + assert m == swap + + def test_composes_with_existing_matrix(self): + """Level calibration should correct tilt while preserving an existing axis swap.""" + import math + + swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y + # Tilted raw: gravity has X component in raw frame + raw = [0.3, 0.0, 0.954] + m = _calibrate_level(raw, swap) + # After calibration, current raw should map to [0, 0, ~1] + mag = math.sqrt(sum(v * v for v in raw)) + norm = [v / mag for v in raw] + result = _mat_vec(m, norm) + assert result[0] == pytest.approx(0, abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(1, abs=1e-5) + # Result should differ from calibrating without the swap + m_no_swap = _calibrate_level(raw) + assert m != m_no_swap + + def test_upside_down(self): + m = _calibrate_level([0, 0, -1.0]) + # 180° about X + assert m == [1, 0, 0, 0, -1, 0, 0, 0, -1] + result = _mat_vec(m, [0, 0, -1]) + assert result[2] == pytest.approx(1, abs=1e-6) + + def test_gravity_along_x(self): + self._assert_maps_to_z([1.0, 0, 0]) + + def test_gravity_along_neg_x(self): + self._assert_maps_to_z([-1.0, 0, 0]) + + def test_gravity_along_y(self): + self._assert_maps_to_z([0, 1.0, 0]) + + def test_tilted_45_degrees(self): + import math + + self._assert_maps_to_z( + [math.sin(math.radians(45)), 0, math.cos(math.radians(45))] + ) + + def test_arbitrary_vector(self): + self._assert_maps_to_z([0.3, -0.5, 0.81]) + + def test_unnormalized_input(self): + """Input does not need to be unit length.""" + self._assert_maps_to_z([0.6, -1.0, 1.62]) + + @pytest.mark.parametrize( + "raw", + [ + [1.0, 0, 0], + [0, 1.0, 0], + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_result_is_proper_rotation(self, raw): + """The resulting matrix should be orthogonal with determinant +1.""" + m = _calibrate_level(raw) + # R^T * R ≈ I + product = _mat_mul(_transpose(m), m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-6) + # det ≈ 1 + assert _det(m) == pytest.approx(1.0, abs=1e-6) + + +class TestCalibrateHeading: + """Verify the Z-rotation heading correction.""" + + def test_y_axis_tilt_no_heading_error(self): + """Device tilted purely around Y — heading should already be correct.""" + import math + + flat_raw = [0, 0, 1.0] + level_m = _calibrate_level(flat_raw) + # Tilt 30° around Y: gravity = [-sin30, 0, cos30] + tilted_raw = [-math.sin(math.radians(30)), 0, math.cos(math.radians(30))] + heading_m = _calibrate_heading(level_m, tilted_raw) + # Matrix should barely change since there's no Y component + for i in range(9): + assert heading_m[i] == pytest.approx(level_m[i], abs=1e-6) + + def test_corrects_heading_rotation(self): + """After level+heading calibration, mapped Y should be ~0 when tilted.""" + import math + + # Simulate a sensor whose chip is rotated 30° around Z relative to enclosure + angle = math.radians(30) + # When the enclosure is flat, the raw reading is [0, 0, 1] regardless of Z rotation + level_m = _calibrate_level([0, 0, 1.0]) + + # When tilted around the enclosure's Y axis, the raw reading in the + # chip frame has both X and Y components due to the Z-rotation offset + tilt = math.radians(20) + # In enclosure frame: [-sin(tilt), 0, cos(tilt)] + # Rotated by Z-angle into chip frame: + ex = -math.sin(tilt) * math.cos(angle) + ey = -math.sin(tilt) * math.sin(angle) + ez = math.cos(tilt) + tilted_raw = [ex, ey, ez] + + heading_m = _calibrate_heading(level_m, tilted_raw) + # After correction, mapped Y should be 0 + result = _mat_vec(heading_m, tilted_raw) + assert result[1] == pytest.approx(0, abs=1e-6) + # Z should still be correct + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-6) + + def test_full_calibration_sequence(self): + """End-to-end: level then heading produces correct frame alignment.""" + import math + + # Chip is mounted tilted 15° around Y and 25° around Z + # Build the chip-to-enclosure rotation: Rz(25°) * Ry(15°) + yz = math.radians(25) + yy = math.radians(15) + # Ry(yy) + ry = [ + math.cos(yy), + 0, + math.sin(yy), + 0, + 1, + 0, + -math.sin(yy), + 0, + math.cos(yy), + ] + # Rz(yz) + rz = [ + math.cos(yz), + -math.sin(yz), + 0, + math.sin(yz), + math.cos(yz), + 0, + 0, + 0, + 1, + ] + chip_rot = _mat_mul(rz, ry) # chip orientation in enclosure frame + # Inverse (transpose) maps enclosure vectors to chip readings + chip_rot_inv = _transpose(chip_rot) + + # Step 1: Device flat — gravity in enclosure frame is [0, 0, 1] + flat_raw = _mat_vec(chip_rot_inv, [0, 0, 1]) + level_m = _calibrate_level(flat_raw) + + # After level calibration, flat reading should map to [0, 0, 1] + check_flat = _mat_vec(level_m, flat_raw) + assert check_flat[0] == pytest.approx(0, abs=1e-5) + assert check_flat[1] == pytest.approx(0, abs=1e-5) + assert check_flat[2] == pytest.approx(1, abs=1e-5) + + # Step 2: Tilt enclosure around Y by 20° + tilt = math.radians(20) + tilted_enclosure = [-math.sin(tilt), 0, math.cos(tilt)] + tilted_raw = _mat_vec(chip_rot_inv, tilted_enclosure) + heading_m = _calibrate_heading(level_m, tilted_raw) + + # After heading calibration, the mapped reading should be + # [-sin(tilt), 0, cos(tilt)] — all horizontal component in X + result = _mat_vec(heading_m, tilted_raw) + assert result[0] == pytest.approx(-math.sin(tilt), abs=1e-5) + assert result[1] == pytest.approx(0, abs=1e-5) + assert result[2] == pytest.approx(math.cos(tilt), abs=1e-5) + + @pytest.mark.parametrize( + "raw", + [ + [0.3, -0.5, 0.81], + [-0.7, 0.4, 0.59], + ], + ) + def test_heading_preserves_orthogonality(self, raw): + """Heading correction composed with level should remain a proper rotation.""" + + level_m = _calibrate_level(raw) + # Create a tilted reading for heading calibration + tilt_raw = [v + 0.3 for v in raw] # perturb to get XY component + heading_m = _calibrate_heading(level_m, tilt_raw) + product = _mat_mul(_transpose(heading_m), heading_m) + for i in range(9): + expected = 1.0 if i % 4 == 0 else 0.0 + assert product[i] == pytest.approx(expected, abs=1e-5) + assert _det(heading_m) == pytest.approx(1.0, abs=1e-5) + + +# --- Calibration action schema & codegen --- + + +class TestCalibrateActionSchema: + """Tests for the CALIBRATE_ACTION_SCHEMA used by both calibration actions.""" + + def test_schema_accepts_on_success_key(self): + """on_success must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_SUCCESS in schema_keys + + def test_schema_accepts_on_error_key(self): + """on_error must be a recognised optional key.""" + schema_keys = {str(k) for k in CALIBRATE_ACTION_SCHEMA.schema} + assert CONF_ON_ERROR in schema_keys + + +@pytest.fixture +def mock_codegen(): + """Mock cg and automation functions used by _build_calibrate_action.""" + mock_var = MagicMock() + mock_parent = MagicMock() + + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ) as mock_get_var, + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch( + "esphome.components.motion.automation.build_automation", + new_callable=AsyncMock, + ) as mock_build_auto, + ): + yield { + "get_variable": mock_get_var, + "new_Pvariable": mock_new_pvar, + "build_automation": mock_build_auto, + "var": mock_var, + "parent": mock_parent, + } + + +@pytest.mark.asyncio +async def test_build_calibrate_action_no_triggers(mock_codegen): + """Without on_success/on_error, build_automation should not be called.""" + config = {CONF_ID: MagicMock()} + action_id = MagicMock() + template_arg = MagicMock() + + result = await _build_calibrate_action(config, action_id, template_arg, []) + + assert result is mock_codegen["var"] + mock_codegen["new_Pvariable"].assert_called_once_with( + action_id, template_arg, mock_codegen["parent"] + ) + mock_codegen["build_automation"].assert_not_called() + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_success(mock_codegen): + """on_success should wire build_automation to get_success_trigger().""" + on_success_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_SUCCESS: on_success_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_success_trigger(), [], on_success_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_on_error(mock_codegen): + """on_error should wire build_automation to get_error_trigger().""" + on_error_config = MagicMock() + config = {CONF_ID: MagicMock(), CONF_ON_ERROR: on_error_config} + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + mock_codegen["build_automation"].assert_called_once_with( + mock_codegen["var"].get_error_trigger(), [], on_error_config + ) + + +@pytest.mark.asyncio +async def test_build_calibrate_action_with_both_triggers(mock_codegen): + """Both on_success and on_error should each produce a build_automation call.""" + on_success_config = MagicMock() + on_error_config = MagicMock() + config = { + CONF_ID: MagicMock(), + CONF_ON_SUCCESS: on_success_config, + CONF_ON_ERROR: on_error_config, + } + + await _build_calibrate_action(config, MagicMock(), MagicMock(), []) + + assert mock_codegen["build_automation"].call_count == 2 + calls = mock_codegen["build_automation"].call_args_list + # First call: on_success + assert calls[0].args == ( + mock_codegen["var"].get_success_trigger(), + [], + on_success_config, + ) + # Second call: on_error + assert calls[1].args == ( + mock_codegen["var"].get_error_trigger(), + [], + on_error_config, + ) + + +# --- Clear calibration action --- + + +class TestClearActionSchema: + """Tests for CLEAR_ACTION_SCHEMA.""" + + def test_schema_has_save_key(self): + schema_keys = {str(k) for k in CLEAR_ACTION_SCHEMA.schema} + assert CONF_SAVE in schema_keys + + def test_save_defaults_to_false(self): + result = CLEAR_ACTION_SCHEMA({CONF_ID: "x"}) + assert result[CONF_SAVE] is False + + +@pytest.fixture +def mock_clear_codegen(): + """Mock cg functions used by clear_calibration_to_code.""" + mock_var = MagicMock() + mock_parent = MagicMock() + with ( + patch( + "esphome.components.motion.cg.get_variable", + new_callable=AsyncMock, + return_value=mock_parent, + ), + patch( + "esphome.components.motion.cg.new_Pvariable", + return_value=mock_var, + ) as mock_new_pvar, + patch("esphome.components.motion.cg.add") as mock_add, + ): + yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} + + +@pytest.mark.asyncio +async def test_clear_action_without_save(mock_clear_codegen): + """With save=False, set_save should not be emitted.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: False} + result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + assert result is mock_clear_codegen["var"] + mock_clear_codegen["add"].assert_not_called() + + +@pytest.mark.asyncio +async def test_clear_action_with_save(mock_clear_codegen): + """With save=True, set_save(True) should be emitted exactly once.""" + config = {CONF_ID: MagicMock(), CONF_SAVE: True} + await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) + mock_clear_codegen["var"].set_save.assert_called_once_with(True) + mock_clear_codegen["add"].assert_called_once() + + +# --- Calibration persistence invalidation --- +# +# The C++ side stores a hash of the build-time base matrix alongside the saved +# calibration so a changed axis_map invalidates stale NVS data without orphaning +# storage (the pref key stays ID-stable). These tests pin the design properties +# of that base-matrix fingerprint: deterministic for identical maps, distinct +# for different ones. + + +def _hash_matrix(matrix: list[float]) -> int: + """Python port of the C++ hash_matrix() (FNV-1a over the float bytes).""" + import struct + + data = struct.pack("<9f", *matrix) + h = 2166136261 + for b in data: + h ^= b + h = (h * 16777619) & 0xFFFFFFFF + return h + + +class TestBaseMatrixHash: + """Properties of the base-matrix fingerprint used for NVS invalidation.""" + + def test_identical_axis_maps_hash_equal(self): + a = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + b = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in a]) == _hash_matrix( + [float(v) for v in b] + ) + + def test_different_axis_maps_hash_differ(self): + identity = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + swapped = _axis_map_to_matrix({"x": "y", "y": "x", "z": "z"}) + assert _hash_matrix([float(v) for v in identity]) != _hash_matrix( + [float(v) for v in swapped] + ) + + def test_sign_change_hashes_differ(self): + pos = _axis_map_to_matrix({"x": "x", "y": "y", "z": "z"}) + neg = _axis_map_to_matrix({"x": "-x", "y": "y", "z": "z"}) + assert _hash_matrix([float(v) for v in pos]) != _hash_matrix( + [float(v) for v in neg] + ) + + +# --- Sensor config schema type validation --- + + +class TestSensorConfigSchema: + """Tests for sensor CONFIG_SCHEMA type key validation.""" + + def test_invalid_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid), match="Unknown value"): + CONFIG_SCHEMA({"type": "invalid_type"}) + + def test_missing_type_rejected(self): + with pytest.raises((Invalid, MultipleInvalid)): + CONFIG_SCHEMA({}) + + @pytest.mark.parametrize( + "sensor_type", + _ACCELERATIONS + _GYROSCOPES + _ANGULAR_RATES + [CONF_PITCH, CONF_ROLL], + ) + def test_valid_types_accepted(self, sensor_type): + """Valid sensor types should pass type validation (errors from missing + required fields like motion_id are expected and acceptable).""" + try: + CONFIG_SCHEMA({"type": sensor_type}) + except (Invalid, MultipleInvalid) as e: + # Should NOT be a type validation error + assert "Unknown value" not in str(e), ( + f"Type '{sensor_type}' was rejected as unknown" + ) From 8400bab9265b0d12252514421294c07a4095d253 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:58:25 -0400 Subject: [PATCH 254/282] [esp32] Make no-default-board variant test explicit about platformio toolchain (#16847) --- tests/component_tests/esp32/test_esp32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e0fcbab0ee..e9fa9446d4 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -83,7 +83,7 @@ def test_esp32_config( id="mismatched_board_variant_config", ), pytest.param( - {"variant": "esp32s31"}, + {"variant": "esp32s31", "toolchain": Toolchain.PLATFORMIO.value}, r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]", id="variant_without_default_board_requires_explicit_board_under_platformio", ), From 64fc09646cdaecab29c55a002435f147a78a2dc4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:00:42 -0400 Subject: [PATCH 255/282] [esp32] Fix clang-tidy on ESP-IDF 6 (#16850) --- .clang-tidy.hash | 2 +- esphome/components/bthome_mithermometer/bthome_ble.cpp | 1 + esphome/components/ledc/ledc_output.cpp | 3 +++ platformio.ini | 9 ++------- script/clang-tidy | 2 ++ sdkconfig.defaults | 8 ++++++++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index e89b4230ad..25ae506732 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d9c755e5f019b2ecb324834717bc1fb8563e622f5751794cb7156d324884481e +58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff12e6157d..ff38ab1740 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -222,6 +222,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da } size_t plaintext_length; + // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, payload.data(), ciphertext_size, &plaintext_length); diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index bfb629143d..62833a7649 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -165,6 +165,8 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); + // Skip under clang-tidy: the inlined HAL MMIO writes trip clang-analyzer-core.FixedAddressDereference +#if !defined(CLANG_TIDY) #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } #elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -174,6 +176,7 @@ void LEDCOutput::setup() { } #else periph_module_reset(PERIPH_LEDC_MODULE); +#endif #endif ledc_peripheral_reset_done = true; } diff --git a/platformio.ini b/platformio.ini index d3fde193b4..182f426a31 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,10 +132,7 @@ 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.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -167,9 +164,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; 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.39/platform-espressif32.zip -platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz +platform = https://github.com/pioarduino/platform-espressif32.git framework = espidf lib_deps = diff --git a/script/clang-tidy b/script/clang-tidy index f19bdb9b56..1416b9b332 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -73,6 +73,7 @@ def clang_options(idedata): "-freorder-blocks", "-fno-jump-tables", "-fno-shrink-wrap", + "-mno-target-align", ) if "zephyr" in triplet: @@ -137,6 +138,7 @@ def clang_options(idedata): if flag not in omit_flags and not flag.startswith("-Werror") and not flag.startswith("-std=") + and not flag.startswith("-mtune=esp") ) cmd.append("-std=gnu++20") diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8d177a7e26..2bd702f48e 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -2,6 +2,14 @@ # (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly # from the source directory (e.g. by IDEs). This should enable all flags that are set by any component. +# clang-tidy analyzes with clang, and IDF 6 only offers newlib under clang +# (picolibc's Kconfig depends on !IDF_TOOLCHAIN_CLANG). The idedata is generated +# with GCC, whose default is picolibc -- but clang-tidy uses the toolchain's +# newlib headers, so a picolibc build config mismatches them (IDF's hal/assert.h +# redeclares abort()/__assert_func() with [[noreturn]] after newlib's stdlib.h, +# tripping clang-diagnostic-error). Pin newlib to match the analyzed headers. +CONFIG_LIBC_NEWLIB=y + # esp32 CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_FREERTOS_HZ=1000 From cbc3770b11709bcdd7b5725bb42bfc8493f30b53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jun 2026 17:30:43 -0500 Subject: [PATCH 256/282] Include model-driven display schemas in the language schema dump (#16872) --- esphome/components/epaper_spi/display.py | 7 ++- esphome/components/mipi/__init__.py | 54 ++++++++++++++++++++++ esphome/components/mipi_dsi/display.py | 2 + esphome/components/mipi_rgb/display.py | 2 + esphome/components/mipi_spi/display.py | 2 + script/build_language_schema.py | 4 ++ tests/script/test_build_language_schema.py | 17 +++++++ 7 files changed, 87 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 658f9e2c4a..b7c56a283a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -5,7 +5,11 @@ from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation -from esphome.components.mipi import flatten_sequence, map_sequence +from esphome.components.mipi import ( + flatten_sequence, + map_sequence, + model_schema_extractor, +) import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( @@ -111,6 +115,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index ccd43c72cf..c3b744c919 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -2,8 +2,12 @@ # Various configuration constants for MIPI displays # Various utility functions for MIPI DBI configuration +from collections.abc import Callable +import functools from typing import Any, Self +import voluptuous as vol + from esphome.components.const import CONF_COLOR_DEPTH from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns import esphome.config_validation as cv @@ -18,6 +22,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, + CONF_MODEL, CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, @@ -27,6 +32,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) @@ -239,6 +245,54 @@ def delay(ms): return DELAY_FLAG, ms +# Generic placeholder model present in every DriverChip registry; skipped when +# choosing a representative model for schema extraction. +_CUSTOM_MODEL = "CUSTOM" + + +def model_schema_extractor( + models: dict[str, Any], + model_schema: Callable[[dict[str, Any]], Any], + extra: dict[str, Any] | None = None, +) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]: + """ + Decorate a model-driven display CONFIG_SCHEMA so the language-schema dumper + can extract it. + + The schema is generated per ``model`` at validation time, so the static + dumper has nothing to walk. When the dumper passes SCHEMA_EXTRACT, resolve a + representative schema for a real model (the generic "CUSTOM" placeholder + over-constrains fields like init_sequence) plus any *extra* keys the model + needs, e.g. a bus mode, and hand that back; runtime validation is untouched. + """ + + def decorate(config_schema: Callable[[Any], Any]) -> Callable[[Any], Any]: + @schema_extractor("schema") + @functools.wraps(config_schema) + def wrapper(config: Any) -> Any: + if config is not SCHEMA_EXTRACT: + return config_schema(config) + names = sorted(models) + representative = next((n for n in names if n != _CUSTOM_MODEL), names[0]) + schema = model_schema({CONF_MODEL: representative, **(extra or {})}) + if isinstance(schema, vol.All): + schema = next( + (v for v in schema.validators if isinstance(v, vol.Schema)), + schema, + ) + if isinstance(schema, vol.Schema): + # The resolved schema pins ``model`` to the representative; expose + # the full model list so the dumped enum offers every model. + schema = schema.extend( + {cv.Required(CONF_MODEL): cv.one_of(*names, upper=True)} + ) + return schema + + return wrapper + + return decorate + + class DriverChip: """ A class representing a MIPI DBI driver chip model. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 0939d84aa5..46e7a7d5a7 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -32,6 +32,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -161,6 +162,7 @@ def model_schema(config): ) +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b38ddad491..3c33c26726 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -30,6 +30,7 @@ from esphome.components.mipi import ( DriverChip, dimension_schema, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -219,6 +220,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema) def _config_schema(config): config = cv.Schema( { diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 3c5a84594e..8c6ffff500 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -22,6 +22,7 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) @@ -227,6 +228,7 @@ def model_schema(config): return schema +@model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 025186299d..4b0b0ee548 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1002,6 +1002,10 @@ def convert(schema, config_var, path): else: config_var["use_id_type"] = str(data.base) config_var[S_TYPE] = "use_id" + elif schema_type == "schema": + # A callable CONFIG_SCHEMA that returned a representative schema + # for extraction (model-driven components); walk it as usual. + convert(data, config_var, path) else: raise TypeError("Unknown extracted schema type") elif config_var.get("key") == "GeneratedID": diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index dd1d88e74c..8b81a57fef 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -117,6 +117,23 @@ def test_convert_emits_explicit_sensitive_marker() -> None: assert config_var["type"] == "string" +def test_convert_walks_callable_schema_extractor() -> None: + """A callable schema tagged for "schema" extraction is resolved and walked.""" + from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + + @schema_extractor("schema") + def dynamic_schema(value): + if value is SCHEMA_EXTRACT: + return cv.Schema({cv.Required("foo"): cv.string}) + return value + + config_var: dict = {} + _bls.convert(dynamic_schema, config_var, "/test") + + assert config_var["type"] == "schema" + assert "foo" in config_var["schema"]["config_vars"] + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 54c73bf1bcb863f2afa53f9c2bbe3c8de885d2bb Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Mon, 8 Jun 2026 09:04:09 -0400 Subject: [PATCH 257/282] [ade7880][airthings_wave_base] Remove kpfleming from CODEOWNERS (#16858) --- CODEOWNERS | 3 +-- esphome/components/ade7880/__init__.py | 1 - esphome/components/airthings_wave_base/__init__.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index abe33f9467..6a81cc1d40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet esphome/components/adc/* @esphome/core esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter -esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu @@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1 esphome/components/ags10/* @mak-42 esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban -esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau +esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index aed63c7dfa..e69de29bb2 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -1 +0,0 @@ -CODEOWNERS = ["@kpfleming"] diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index c3f3b8f199..dee26b524a 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( UNIT_VOLT, ) -CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"] +CODEOWNERS = ["@ncareau", "@jeromelaban"] DEPENDENCIES = ["ble_client"] From 36e043debb277f3830ca9ac566b08d143001b054 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jun 2026 12:49:25 -0500 Subject: [PATCH 258/282] [tests] Fail component test merge on conflicting duplicate IDs (#16849) --- .github/workflows/ci.yml | 1 + script/analyze_component_buses.py | 9 +- script/ci_check_duplicate_test_ids.py | 214 ++++++++++++++++++ script/merge_component_configs.py | 164 ++++++++------ tests/components/adc/test.bk72xx-ard.yaml | 2 +- tests/components/adc/test.esp32-c2-idf.yaml | 2 +- tests/components/adc/test.esp32-c3-idf.yaml | 2 +- tests/components/adc/test.esp32-idf.yaml | 2 +- tests/components/adc/test.esp32-p4-idf.yaml | 2 +- tests/components/adc/test.esp32-s2-idf.yaml | 2 +- tests/components/adc/test.esp32-s3-idf.yaml | 2 +- tests/components/adc/test.esp8266-ard.yaml | 2 +- tests/components/adc/test.ln882x-ard.yaml | 2 +- tests/components/adc/test.rp2040-ard.yaml | 2 +- .../components/adc/test.rp2040-pico2-ard.yaml | 2 +- .../alarm_control_panel/common.yaml | 6 +- .../components/animation/test.esp32-idf.yaml | 2 +- .../animation/test.esp8266-ard.yaml | 2 +- .../components/animation/test.rp2040-ard.yaml | 2 +- tests/components/api/common-base.yaml | 2 +- tests/components/audio_file/common.yaml | 2 +- .../audio_file/validate.esp32-idf.yaml | 2 +- tests/components/axs15231/common.yaml | 4 +- .../components/axs15231/test.esp8266-ard.yaml | 4 +- tests/components/bang_bang/common.yaml | 12 +- tests/components/binary_sensor/common.yaml | 6 +- .../components/binary_sensor_map/common.yaml | 24 +- tests/components/ble_client/common.yaml | 4 +- tests/components/canbus/common.yaml | 6 +- tests/components/cd74hc4067/common.yaml | 6 +- tests/components/climate_ir_lg/common.yaml | 4 +- .../components/color_temperature/common.yaml | 8 +- tests/components/copy/common.yaml | 8 +- tests/components/ct_clamp/common.yaml | 4 +- tests/components/current_based/common.yaml | 6 +- tests/components/cwww/common.yaml | 4 +- tests/components/cwww/test.esp32-idf.yaml | 4 +- tests/components/cwww/test.esp8266-ard.yaml | 4 +- tests/components/cwww/test.rp2040-ard.yaml | 4 +- tests/components/duty_time/common.yaml | 4 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- tests/components/ektf2232/common.yaml | 4 +- tests/components/endstop/common.yaml | 12 +- tests/components/esp32_can/common.yaml | 2 +- .../esp32_can/test.esp32-c6-idf.yaml | 6 +- tests/components/espnow/common.yaml | 6 +- .../components/fastled_clockless/common.yaml | 6 +- tests/components/fastled_spi/common.yaml | 6 +- tests/components/font/common.yaml | 6 +- tests/components/font/test.host.yaml | 6 +- tests/components/graph/common.yaml | 2 +- .../graphical_display_menu/common.yaml | 17 +- tests/components/gt911/common.yaml | 4 +- tests/components/homeassistant/common.yaml | 4 +- tests/components/image/test.esp32-idf.yaml | 2 +- tests/components/image/test.esp8266-ard.yaml | 2 +- tests/components/image/test.rp2040-ard.yaml | 2 +- tests/components/infrared/common.yaml | 2 +- .../components/integration/common-esp32.yaml | 4 +- .../integration/test.esp8266-ard.yaml | 4 +- .../integration/test.rp2040-ard.yaml | 4 +- tests/components/ir_rf_proxy/common-rx.yaml | 2 +- tests/components/lcd_gpio/common.yaml | 2 +- tests/components/lcd_menu/common.yaml | 8 +- tests/components/light/common.yaml | 2 +- tests/components/light/test.esp32-idf.yaml | 2 +- tests/components/light/test.esp8266-ard.yaml | 2 +- .../components/light/test.nrf52-adafruit.yaml | 4 +- tests/components/light/test.nrf52-mcumgr.yaml | 4 +- tests/components/light/test.rp2040-ard.yaml | 2 +- tests/components/lilygo_t5_47/common.yaml | 4 +- tests/components/lock/common.yaml | 4 +- tests/components/monochromatic/common.yaml | 4 +- tests/components/mpr121/common.yaml | 6 +- tests/components/mqtt/common.yaml | 20 +- .../components/mqtt_subscribe/common-ard.yaml | 4 +- .../components/mqtt_subscribe/common-idf.yaml | 4 +- tests/components/ntc/common.yaml | 10 +- tests/components/number/common.yaml | 4 +- .../components/online_image/common-esp32.yaml | 2 +- .../online_image/common-esp8266.yaml | 2 +- .../online_image/common-rp2040.yaml | 2 +- .../online_image/test.esp32-s3-ard.yaml | 2 +- .../online_image/test.esp32-s3-idf.yaml | 2 +- tests/components/output/common.yaml | 12 +- tests/components/pi4ioe5v6408/common.yaml | 2 +- tests/components/pid/common.yaml | 6 +- tests/components/prometheus/common.yaml | 6 +- tests/components/qspi_dbi/common.yaml | 2 +- .../remote_transmitter/common-buttons.yaml | 6 +- tests/components/resistance/common.yaml | 6 +- tests/components/rgb/common.yaml | 12 +- tests/components/rgbct/common.yaml | 20 +- tests/components/rgbw/common.yaml | 16 +- tests/components/rgbww/common.yaml | 20 +- .../rp2040_pio_led_strip/common.yaml | 2 +- tests/components/rp2040_pwm/common.yaml | 4 +- tests/components/sdl/common.yaml | 8 +- tests/components/speaker/common.yaml | 4 +- tests/components/speaker_source/common.yaml | 2 +- tests/components/speed/common.yaml | 6 +- tests/components/sprinkler/common.yaml | 12 +- tests/components/ssd1306_i2c/common.yaml | 2 +- tests/components/switch/common.yaml | 2 +- tests/components/sx126x/common.yaml | 4 +- tests/components/sx127x/common.yaml | 4 +- tests/components/template/common-base.yaml | 40 ++-- tests/components/tlc5947/common.yaml | 4 +- tests/components/tlc5971/common.yaml | 4 +- tests/components/tt21100/common.yaml | 4 +- tests/components/uart/test.esp32-idf.yaml | 4 +- tests/components/udp/common.yaml | 4 +- tests/components/ufire_ec/common.yaml | 6 +- tests/components/ufire_ise/common.yaml | 4 +- tests/components/web_server_idf/common.yaml | 4 +- tests/components/wk2132_i2c/common.yaml | 2 +- tests/components/wk2132_spi/common.yaml | 2 +- tests/components/wk2168_i2c/common.yaml | 2 +- tests/components/wk2168_spi/common.yaml | 2 +- tests/components/wk2204_i2c/common.yaml | 2 +- tests/components/wk2204_spi/common.yaml | 2 +- tests/components/wk2212_i2c/common.yaml | 2 +- tests/components/wk2212_spi/common.yaml | 2 +- .../test_ci_check_duplicate_test_ids.py | 114 ++++++++++ tests/script/test_merge_component_configs.py | 101 +++++++++ 125 files changed, 836 insertions(+), 372 deletions(-) create mode 100755 script/ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_ci_check_duplicate_test_ids.py create mode 100644 tests/script/test_merge_component_configs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d604f248..3115b5b473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + script/ci_check_duplicate_test_ids.py import-time: name: Check import esphome.__main__ time diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a343e34328..8eb80d9943 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -39,8 +39,13 @@ from helpers import BASE_BUS_COMPONENTS, is_validate_only_file from esphome import yaml_util from esphome.config_helpers import Extend, Remove -# Path to common bus configs -COMMON_BUS_PATH = Path("tests/test_build_components/common") +# Path to common bus configs (resolved relative to this file, not the CWD) +COMMON_BUS_PATH = ( + Path(__file__).resolve().parent.parent + / "tests" + / "test_build_components" + / "common" +) # Package dependencies - maps packages to the packages they include # When a component uses a package on the left, it automatically gets diff --git a/script/ci_check_duplicate_test_ids.py b/script/ci_check_duplicate_test_ids.py new file mode 100755 index 0000000000..13da66c9b1 --- /dev/null +++ b/script/ci_check_duplicate_test_ids.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Fail when two component test fixtures define the same id with different content. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). When two components declare the same id +under the same section but with different content, the merge keeps the first and +drops the rest, which can make a cross-reference resolve to an incompatible +entity (this is what broke the i2s_audio speaker tests). That only surfaces when +the two components happen to land in the same group, often in an unrelated PR +long after the duplicate was written. + +This script is the complete, batch-independent guard: it scans every component's +``test..yaml`` per platform and reports any id that is defined by more +than one component with differing content, so a collision fails the PR that +introduces it and names the exact id and components. + +To stay byte-for-byte consistent with what the merge actually does (so the guard +never disagrees with the build), it reuses the merge's own helpers: + +* ``prefix_substitutions_in_dict`` -- the merge prefixes every component's + substitution references with the component name before deduplicating, so e.g. + ``pin: ${pin}`` in two components becomes ``${a_pin}`` and ``${b_pin}`` and + conflicts. We apply the same prefixing; otherwise a shared id whose only + difference is a substitution looks identical here but conflicts at merge time. +* ``deduplicate_by_id`` -- the actual merge comparison (including the + ``INTENTIONALLY_SHARED_IDS`` allowlist for deliberately shared singletons such + as ``sntp_time``). We feed each shared id's prefixed items straight through it + and treat a raised ``ValueError`` as a conflict, so this check and the merge + can never diverge. + +``packages:`` are left as opaque ``!include`` objects by the loader -- exactly as +the merge sees them at dedup time -- so package-provided bus ids (``i2c_bus`` ...) +are not compared here, matching the merge, which re-adds those packages once. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from esphome.core import EsphomeError # noqa: E402 +from script.merge_component_configs import ( # noqa: E402 + deduplicate_by_id, + load_yaml_file, + prepare_component_body, +) + +# Resolved relative to this file (not the CWD) so the scan cannot silently cover +# nothing when run from a different directory. +TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" / "components" + + +def _collect_ids( + data: object, + path: tuple[str, ...], + out: dict[tuple[tuple[str, ...], object], object], +) -> None: + """Record (dict_path, id) -> item for id-bearing items in dict-reachable lists. + + Keyed by the full dict path (not just the immediate key) so items under + different paths that happen to share a list key name are never compared. Only + lists reached purely through dict keys are recorded: once the merge + concatenates a list, items from different components live in separate elements, + so anything deeper is never compared across components (matching how + ``merge_config`` combines bodies). Ids keep their original type so ``5`` and + ``"5"`` stay distinct, exactly as ``deduplicate_by_id`` treats them; an + unhashable id (rare) falls back to its ``repr`` so it can still be grouped. + """ + if not isinstance(data, dict): + return + for key, value in data.items(): + new_path = path + (key,) + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and "id" in item: + item_id = item["id"] + try: + hash(item_id) + except TypeError: + item_id = repr(item_id) + out[(new_path, item_id)] = item + elif isinstance(value, dict): + _collect_ids(value, new_path, out) + + +def _discover_platforms() -> set[str]: + platforms: set[str] = set() + for test_file in TESTS_DIR.glob("*/test.*.yaml"): + # test..yaml -> platform is the middle dotted part + parts = test_file.name.split(".") + if len(parts) == 3: + platforms.add(parts[1]) + return platforms + + +def _load_components( + platform: str, parse_errors: list[str] +) -> Iterator[tuple[str, object]]: + """Yield (component, prefixed config) for each component testing this platform. + + Each body is prepared with ``prepare_component_body`` (the same helper the + merge uses: it expands component-specific package includes and prefixes + substitutions), so the comparison sees what the build merges. Fixtures that + fail to parse are recorded in ``parse_errors`` so the run can fail rather than + silently skip them. + """ + for comp_dir in sorted(TESTS_DIR.iterdir()): + test_file = comp_dir / f"test.{platform}.yaml" + if not comp_dir.is_dir() or not test_file.exists(): + continue + try: + data = load_yaml_file(test_file) + except EsphomeError as err: + parse_errors.append(str(test_file)) + print(f"ERROR: could not parse {test_file}: {err}", file=sys.stderr) + continue + yield comp_dir.name, prepare_component_body(data, comp_dir.name, comp_dir) + + +@dataclass +class ScanResult: + """Outcome of a scan. A caller cannot observe a clean result while files were + skipped or nothing was scanned -- all three fields are reported together.""" + + conflicts: list[str] = field(default_factory=list) + parse_errors: list[str] = field(default_factory=list) + components_scanned: int = 0 + + +def scan() -> ScanResult: + """Scan every component's base test fixture and report cross-component id conflicts. + + Only base ``test..yaml`` fixtures are scanned because only those are + combined by ``merge_component_configs`` in grouped CI builds; variant + (``test-*.yaml``) fixtures are built individually and never cross-merged. + """ + result = ScanResult() + for platform in sorted(_discover_platforms()): + # (dict_path, id) -> {component: prefixed_item} + groups: dict[tuple[tuple[str, ...], object], dict[str, object]] = defaultdict( + dict + ) + for component, data in _load_components(platform, result.parse_errors): + result.components_scanned += 1 + collected: dict[tuple[tuple[str, ...], object], object] = {} + _collect_ids(data, (), collected) + for key, item in collected.items(): + groups[key][component] = item + + for (path, id_), by_component in sorted( + groups.items(), key=lambda kv: (kv[0][0], str(kv[0][1])) + ): + if len(by_component) < 2: + continue + # Delegate the decision to the merge's own deduplication so this guard + # can never disagree with what the build does. + try: + deduplicate_by_id({path[-1]: list(by_component.values())}) + except ValueError: + result.conflicts.append( + f"[{platform}] id '{id_}' under '{'.'.join(path)}' is defined " + f"differently by: {', '.join(sorted(by_component))}" + ) + return result + + +def main() -> int: + result = scan() + if result.conflicts: + print("Conflicting test component ids found:\n") + for line in result.conflicts: + print(f" - {line}") + print( + "\nGive each component a unique id (e.g. '_'), or add the " + "id to INTENTIONALLY_SHARED_IDS in script/merge_component_configs.py if " + "it is a deliberately shared singleton." + ) + + if result.parse_errors: + # A fixture we could not parse was never scanned, so the run is not a + # clean pass even if no conflicts were found among the rest. + print( + f"\n{len(result.parse_errors)} test fixture(s) could not be parsed and " + "were not checked:" + ) + for path in result.parse_errors: + print(f" - {path}") + + if result.components_scanned == 0: + # A scan that covered nothing is a false green -- the whole point of the + # guard is defeated. Fail loudly (wrong working directory or layout change). + print( + f"\nERROR: scanned 0 component test fixtures under {TESTS_DIR}; " + "the guard covered nothing.", + file=sys.stderr, + ) + + if result.conflicts or result.parse_errors or result.components_scanned == 0: + return 1 + + print( + f"No conflicting test component ids found " + f"({result.components_scanned} fixtures scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a952ecff16..5eeeafac2a 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -161,18 +161,46 @@ def prefix_substitutions_in_dict( return data +# (section, id) pairs that several components intentionally share. ESPHome +# treats these as a single instance when merged, so duplicates with differing +# content are expected and must not be flagged as accidental collisions. Keyed on +# the section as well as the id so a generic name (e.g. `ldo_id`) is only exempt +# in its intended section -- an accidental collision on the same name elsewhere +# is still caught. +INTENTIONALLY_SHARED_IDS = frozenset( + { + # Several components each declare an `sntp_time` clock; ESPHome merges + # them into one time source. + ("time", "sntp_time"), + # esp_ldo and mipi_dsi both configure the channel-3 internal LDO on the + # ESP32-P4; only one LDO per channel may exist, so the shared id lets the + # merge collapse them into a single LDO. + ("esp_ldo", "ldo_id"), + } +) + + def deduplicate_by_id(data: dict) -> dict: """Deduplicate list items with the same ID. - Keeps only the first occurrence of each ID. If items with the same ID - are identical, this silently deduplicates. If they differ, the first - one is kept (ESPHome's validation will catch if this causes issues). + Identical items sharing an ID (e.g. a shared bus from a common package pulled + in by several components) are collapsed to the first occurrence. Two items + that share an ID but differ in content are a real conflict: when merged, the + first silently wins and the others are dropped, which can make a + cross-reference resolve to an incompatible entity. Rather than defer that to + downstream validation (where it surfaces as a confusing, order-dependent + failure in an unrelated build), raise immediately so the offending ID is + named. Ids in ``INTENTIONALLY_SHARED_IDS`` are deliberately shared singletons + and keep their collapse behaviour. Args: data: Parsed config dictionary Returns: Config with deduplicated lists + + Raises: + ValueError: If two items share an ID but have different content. """ if not isinstance(data, dict): return data @@ -181,16 +209,25 @@ def deduplicate_by_id(data: dict) -> dict: for key, value in data.items(): if isinstance(value, list): # Check for items with 'id' field - seen_ids = set() + seen_items: dict[str, Any] = {} deduped_list = [] for item in value: if isinstance(item, dict) and "id" in item: item_id = item["id"] - if item_id not in seen_ids: - seen_ids.add(item_id) + if item_id not in seen_items: + seen_items[item_id] = item deduped_list.append(item) - # else: skip duplicate ID (keep first occurrence) + elif (key, item_id) in INTENTIONALLY_SHARED_IDS: + # Deliberately shared singleton -> keep first occurrence. + pass + elif item != seen_items[item_id]: + raise ValueError( + f"Conflicting definitions for id '{item_id}' under " + f"'{key}' when merging test configs; give each " + f"component a unique id" + ) + # else: identical duplicate (e.g. shared bus package) -> skip else: # No ID, just add it deduped_list.append(item) @@ -205,6 +242,55 @@ def deduplicate_by_id(data: dict) -> dict: return result +def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> dict: + """Return a component's test body as it enters the merge. + + Expands component-specific package includes inline (common bus packages are + left for the merge to re-add once), applies ESPHome's top-level-substitutions + -override-package-substitutions rule, then prefixes every substitution + reference with the component name. Shared by ``merge_component_configs`` and + the duplicate-id guard (``script/ci_check_duplicate_test_ids.py``) so the + guard compares exactly what the build merges. + """ + # $component_dir resolves to the component's absolute path. + comp_abs_dir = str(comp_dir.absolute()) + + # Top-level substitutions override package substitutions, so capture them + # before expanding packages can introduce their own. + top_level_subs = ( + comp_data["substitutions"].copy() + if isinstance(comp_data.get("substitutions"), dict) + else {} + ) + + packages_value = comp_data.get("packages") + if isinstance(packages_value, dict): + common_bus_packages = get_common_bus_packages() + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + elif isinstance(packages_value, list): + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + # Common bus packages are re-added once by the caller; drop them here. + comp_data.pop("packages", None) + + subs = comp_data.get("substitutions") or {} + subs.update(top_level_subs) + prefixed_subs = {f"{comp_name}_{name}": value for name, value in subs.items()} + prefixed_subs[f"{comp_name}_component_dir"] = comp_abs_dir + comp_data["substitutions"] = prefixed_subs + + return prefix_substitutions_in_dict(comp_data, comp_name) + + def merge_component_configs( component_names: list[str], platform: str, @@ -266,67 +352,9 @@ def merge_component_configs( # New package type - add it all_packages[pkg_name] = pkg_config - # Handle $component_dir by replacing with absolute path - # This allows components that use local file references to be grouped - comp_abs_dir = str(comp_dir.absolute()) - - # Save top-level substitutions BEFORE expanding packages - # In ESPHome, top-level substitutions override package substitutions - top_level_subs = ( - comp_data["substitutions"].copy() - if "substitutions" in comp_data and comp_data["substitutions"] is not None - else {} - ) - - # Expand packages - but we'll restore substitution priority after - if "packages" in comp_data: - packages_value = comp_data["packages"] - - if isinstance(packages_value, dict): - # Dict format - check each package - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - # Component-specific package - expand its content into top level - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - # List format - expand all package includes - for pkg_value in packages_value: - # Resolve deferred !include files before checking type - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if not isinstance(pkg_value, dict): - continue - comp_data = merge_config(comp_data, pkg_value) - - # Remove all packages (common will be re-added at the end) - del comp_data["packages"] - - # Restore top-level substitution priority - # Top-level substitutions override any from packages - if "substitutions" not in comp_data or comp_data["substitutions"] is None: - comp_data["substitutions"] = {} - - # Merge: package subs as base, top-level subs override - comp_data["substitutions"].update(top_level_subs) - - # Now prefix the final merged substitutions - comp_data["substitutions"] = { - f"{comp_name}_{sub_name}": sub_value - for sub_name, sub_value in comp_data["substitutions"].items() - } - - # Add component_dir substitution with absolute path for this component - comp_data["substitutions"][f"{comp_name}_component_dir"] = comp_abs_dir - - # Prefix substitution references throughout the config - comp_data = prefix_substitutions_in_dict(comp_data, comp_name) + # Expand component-specific packages and prefix substitutions, exactly as + # the duplicate-id guard does, so both see the same body. + comp_data = prepare_component_body(comp_data, comp_name, comp_dir) # Use ESPHome's merge_config to merge this component into the result # merge_config handles list merging with ID-based deduplication automatically diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/test.bk72xx-ard.yaml index 0645333a81..09ef0e1fad 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/test.bk72xx-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: P23 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c2-idf.yaml b/tests/components/adc/test.esp32-c2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c2-idf.yaml +++ b/tests/components/adc/test.esp32-c2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-c3-idf.yaml b/tests/components/adc/test.esp32-c3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-c3-idf.yaml +++ b/tests/components/adc/test.esp32-c3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-idf.yaml b/tests/components/adc/test.esp32-idf.yaml index ff1e3bb919..f31e0e087d 100644 --- a/tests/components/adc/test.esp32-idf.yaml +++ b/tests/components/adc/test.esp32-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A0 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml index b77dc299c2..77cf50d17c 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO16 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/test.esp32-s2-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/test.esp32-s2-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp32-s3-idf.yaml b/tests/components/adc/test.esp32-s3-idf.yaml index e764f0fe21..a3019466b5 100644 --- a/tests/components/adc/test.esp32-s3-idf.yaml +++ b/tests/components/adc/test.esp32-s3-idf.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: GPIO1 name: ADC Test sensor diff --git a/tests/components/adc/test.esp8266-ard.yaml b/tests/components/adc/test.esp8266-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.esp8266-ard.yaml +++ b/tests/components/adc/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml index face38b647..d899259773 100644 --- a/tests/components/adc/test.ln882x-ard.yaml +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: A5 name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-ard.yaml b/tests/components/adc/test.rp2040-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-ard.yaml +++ b/tests/components/adc/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml index 4cc865bb5d..617464818b 100644 --- a/tests/components/adc/test.rp2040-pico2-ard.yaml +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -1,5 +1,5 @@ sensor: - - id: my_sensor + - id: adc_my_sensor platform: adc pin: VCC name: ADC Test sensor diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 39d5739255..327234d6ca 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: gpio - id: bin1 + id: alarm_control_panel_bin1 pin: 1 alarm_control_panel: @@ -18,7 +18,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true @@ -39,7 +39,7 @@ alarm_control_panel: pending_time: 15s trigger_time: 30s binary_sensors: - - input: bin1 + - input: alarm_control_panel_bin1 bypass_armed_home: true bypass_armed_night: true bypass_auto: true diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index c28e9584dd..b844f5ae92 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 12 diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index 11a7117d91..a7937ffca2 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 2c99e937f3..2cbb254adf 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: animation_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index ca86445777..060254990d 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -296,7 +296,7 @@ api: event: - platform: template name: Test Event - id: test_event + id: api_test_event event_types: - single_click - double_click diff --git a/tests/components/audio_file/common.yaml b/tests/components/audio_file/common.yaml index e7f55b4806..02cb3814f2 100644 --- a/tests/components/audio_file/common.yaml +++ b/tests/components/audio_file/common.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/audio_file/validate.esp32-idf.yaml b/tests/components/audio_file/validate.esp32-idf.yaml index 085f853c8e..1d8d4646fa 100644 --- a/tests/components/audio_file/validate.esp32-idf.yaml +++ b/tests/components/audio_file/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ audio_file: - - id: test_audio + - id: audio_file_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index d4fd3becbb..03e82ab26e 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: axs15231_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_i2c_display + display: axs15231_ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/axs15231/test.esp8266-ard.yaml b/tests/components/axs15231/test.esp8266-ard.yaml index eb599da773..245b87bec9 100644 --- a/tests/components/axs15231/test.esp8266-ard.yaml +++ b/tests/components/axs15231/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: axs15231_ssd1306_display model: SSD1306_128X64 reset_pin: 13 pages: @@ -15,5 +15,5 @@ display: touchscreen: - platform: axs15231 i2c_id: i2c_bus - display: ssd1306_display + display: axs15231_ssd1306_display interrupt_pin: 12 diff --git a/tests/components/bang_bang/common.yaml b/tests/components/bang_bang/common.yaml index 5882025191..28798f8173 100644 --- a/tests/components/bang_bang/common.yaml +++ b/tests/components/bang_bang/common.yaml @@ -1,6 +1,6 @@ switch: - platform: template - id: template_switch1 + id: bang_bang_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -8,7 +8,7 @@ switch: sensor: - platform: template - id: template_sensor1 + id: bang_bang_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -20,16 +20,16 @@ sensor: climate: - platform: bang_bang name: Bang Bang Climate - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: bang_bang_template_sensor1 + humidity_sensor: bang_bang_template_sensor1 default_target_temperature_low: 18°C default_target_temperature_high: 24°C idle_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 cool_action: - switch.turn_on: template_switch2 heat_action: - - switch.turn_on: template_switch1 + - switch.turn_on: bang_bang_template_switch1 away_config: default_target_temperature_low: 16°C default_target_temperature_high: 20°C diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index e3fd159b08..4f4cf6ea59 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -1,7 +1,7 @@ binary_sensor: - platform: template trigger_on_initial_state: true - id: some_binary_sensor + id: binary_sensor_some_binary_sensor name: "Random binary" lambda: return (random_uint32() & 1) == 0; filters: @@ -21,7 +21,7 @@ binary_sensor: time_off: 100ms time_on: 400ms - lambda: |- - if (id(some_binary_sensor).state) { + if (id(binary_sensor_some_binary_sensor).state) { return x; } return {}; @@ -36,7 +36,7 @@ binary_sensor: - logger.log: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - - binary_sensor.invalidate_state: some_binary_sensor + - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor # Test autorepeat with default configuration (no timings) - platform: template diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index c054022583..667d0be9e7 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -1,20 +1,20 @@ binary_sensor: - platform: template - id: bin1 + id: binary_sensor_map_bin1 lambda: |- if (millis() > 10000) { return true; } return false; - platform: template - id: bin2 + id: binary_sensor_map_bin2 lambda: |- if (millis() > 20000) { return true; } return false; - platform: template - id: bin3 + id: binary_sensor_map_bin3 lambda: |- if (millis() > 30000) { return true; @@ -26,33 +26,33 @@ sensor: name: Binary Sensor Map Group type: group channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Sum type: sum channels: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 value: 10.0 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 value: 15.0 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 value: 100.0 - platform: binary_sensor_map name: Binary Sensor Map Bayesian type: bayesian prior: 0.4 observations: - - binary_sensor: bin1 + - binary_sensor: binary_sensor_map_bin1 prob_given_true: 0.9 prob_given_false: 0.4 - - binary_sensor: bin2 + - binary_sensor: binary_sensor_map_bin2 prob_given_true: 0.7 prob_given_false: 0.05 - - binary_sensor: bin3 + - binary_sensor: binary_sensor_map_bin3 prob_given_true: 0.8 prob_given_false: 0.2 diff --git a/tests/components/ble_client/common.yaml b/tests/components/ble_client/common.yaml index 4ea1dd60f3..4ed6ad7fc9 100644 --- a/tests/components/ble_client/common.yaml +++ b/tests/components/ble_client/common.yaml @@ -56,7 +56,7 @@ sensor: number: - platform: template name: "Test Number" - id: test_number + id: ble_client_test_number optimistic: true min_value: 0 max_value: 255 @@ -72,5 +72,5 @@ button: service_uuid: "abcd1234-abcd-1234-abcd-abcd12345678" characteristic_uuid: "abcd1235-abcd-1234-abcd-abcd12345678" value: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(ble_client_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index e779f7f078..3ba3564608 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -1,6 +1,6 @@ canbus: - platform: esp32_can - id: esp32_internal_can + id: canbus_esp32_internal_can rx_pin: 4 tx_pin: 5 can_id: 4 @@ -40,7 +40,7 @@ canbus: number: - platform: template name: "Test Number" - id: test_number + id: canbus_test_number optimistic: true min_value: 0 max_value: 255 @@ -62,5 +62,5 @@ button: - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- - uint8_t val = (uint8_t)id(test_number).state; + uint8_t val = (uint8_t)id(canbus_test_number).state; return std::vector{0xAA, val, 0xBB}; diff --git a/tests/components/cd74hc4067/common.yaml b/tests/components/cd74hc4067/common.yaml index 9afb39cd31..c217ce9d39 100644 --- a/tests/components/cd74hc4067/common.yaml +++ b/tests/components/cd74hc4067/common.yaml @@ -6,13 +6,13 @@ cd74hc4067: sensor: - platform: adc - id: esp_adc_sensor + id: cd74hc4067_esp_adc_sensor pin: ${pin} - platform: cd74hc4067 id: cd74hc4067_adc_0 number: 0 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor - platform: cd74hc4067 id: cd74hc4067_adc_1 number: 1 - sensor: esp_adc_sensor + sensor: cd74hc4067_esp_adc_sensor diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index 37011b16ee..e0bc185d2c 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,6 +1,6 @@ sensor: - platform: template - id: temp_sensor + id: climate_ir_lg_temp_sensor lambda: return 22.0; update_interval: 60s - platform: template @@ -12,5 +12,5 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr - sensor: temp_sensor + sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor diff --git a/tests/components/color_temperature/common.yaml b/tests/components/color_temperature/common.yaml index fe0c5bf917..0db54d10d0 100644 --- a/tests/components/color_temperature/common.yaml +++ b/tests/components/color_temperature/common.yaml @@ -1,15 +1,15 @@ output: - platform: ${light_platform} - id: light_output_1 + id: color_temperature_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: color_temperature_light_output_2 pin: ${pin_o2} light: - platform: color_temperature name: Lights - color_temperature: light_output_1 - brightness: light_output_2 + color_temperature: color_temperature_light_output_1 + brightness: color_temperature_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index a376004b2f..cbd056f070 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -1,17 +1,17 @@ output: - platform: ${pwm_platform} - id: fan_output_1 + id: copy_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: copy_fan_speed + output: copy_fan_output_1 preset_modes: - Eco - Turbo - platform: copy - source_id: fan_speed + source_id: copy_fan_speed name: Fan Speed Copy select: diff --git a/tests/components/ct_clamp/common.yaml b/tests/components/ct_clamp/common.yaml index 3ed9678447..656b1971a5 100644 --- a/tests/components/ct_clamp/common.yaml +++ b/tests/components/ct_clamp/common.yaml @@ -1,9 +1,9 @@ sensor: - platform: adc - id: esp_adc_sensor + id: ct_clamp_esp_adc_sensor pin: ${pin} - platform: ct_clamp - sensor: esp_adc_sensor + sensor: ct_clamp_esp_adc_sensor name: CT Clamp sample_duration: 500ms update_interval: 5s diff --git a/tests/components/current_based/common.yaml b/tests/components/current_based/common.yaml index 503c4596e9..139571ccec 100644 --- a/tests/components/current_based/common.yaml +++ b/tests/components/current_based/common.yaml @@ -31,7 +31,7 @@ sensor: switch: - platform: template - id: template_switch1 + id: current_based_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -46,7 +46,7 @@ cover: open_obstacle_current_threshold: 0.8 open_duration: 12s open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: current_based_template_switch1 close_sensor: ade7953_current_b close_moving_current_threshold: 0.5 close_obstacle_current_threshold: 0.8 @@ -54,7 +54,7 @@ cover: close_action: - switch.turn_on: template_switch2 stop_action: - - switch.turn_off: template_switch1 + - switch.turn_off: current_based_template_switch1 - switch.turn_off: template_switch2 obstacle_rollback: 30% start_sensing_delay: 0.8s diff --git a/tests/components/cwww/common.yaml b/tests/components/cwww/common.yaml index 7fa5ab668c..bbb6c9182b 100644 --- a/tests/components/cwww/common.yaml +++ b/tests/components/cwww/common.yaml @@ -1,8 +1,8 @@ light: - platform: cwww name: CWWW Light - cold_white: light_output_1 - warm_white: light_output_2 + cold_white: cwww_light_output_1 + warm_white: cwww_light_output_2 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds constant_brightness: true diff --git a/tests/components/cwww/test.esp32-idf.yaml b/tests/components/cwww/test.esp32-idf.yaml index 01edf0b0b5..0665879b08 100644 --- a/tests/components/cwww/test.esp32-idf.yaml +++ b/tests/components/cwww/test.esp32-idf.yaml @@ -5,11 +5,11 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} channel: 0 - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} channel: 1 phase_angle: 180° diff --git a/tests/components/cwww/test.esp8266-ard.yaml b/tests/components/cwww/test.esp8266-ard.yaml index 49d73b7d3d..bb1868fdef 100644 --- a/tests/components/cwww/test.esp8266-ard.yaml +++ b/tests/components/cwww/test.esp8266-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/cwww/test.rp2040-ard.yaml b/tests/components/cwww/test.rp2040-ard.yaml index ba8e0ad071..27fc930b68 100644 --- a/tests/components/cwww/test.rp2040-ard.yaml +++ b/tests/components/cwww/test.rp2040-ard.yaml @@ -5,10 +5,10 @@ substitutions: output: - platform: ${light_platform} - id: light_output_1 + id: cwww_light_output_1 pin: ${pin_o1} - platform: ${light_platform} - id: light_output_2 + id: cwww_light_output_2 pin: ${pin_o2} <<: !include common.yaml diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 761d10f16a..12e4397c49 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: duty_time_bin1 lambda: |- if (millis() > 10000) { return true; @@ -10,4 +10,4 @@ binary_sensor: sensor: - platform: duty_time name: Duty Time - sensor: bin1 + sensor: duty_time_bin1 diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 25fe3b6796..4593784ef9 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -2,7 +2,7 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: e131_led_strip pin: 2 pio: 0 num_leds: 256 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 1c4d768b08..070b03eeb9 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: ektf2232_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_i2c_display + display: ektf2232_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index b92b1e13b9..6f5cf61268 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: template - id: bin1 + id: endstop_bin1 lambda: |- if (millis() > 10000) { return true; @@ -9,7 +9,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: endstop_template_switch1 optimistic: true - platform: template id: template_switch2 @@ -20,12 +20,12 @@ cover: id: endstop_cover name: Endstop Cover stop_action: - - switch.turn_on: template_switch1 - open_endstop: bin1 + - switch.turn_on: endstop_template_switch1 + open_endstop: endstop_bin1 open_action: - - switch.turn_on: template_switch1 + - switch.turn_on: endstop_template_switch1 open_duration: 5min - close_endstop: bin1 + close_endstop: endstop_bin1 close_action: - switch.turn_on: template_switch2 close_duration: 4.5min diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index 3b9b33c048..f15b609d84 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -13,7 +13,7 @@ esphome: canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: ${rx_pin} tx_pin: ${tx_pin} can_id: 4 diff --git a/tests/components/esp32_can/test.esp32-c6-idf.yaml b/tests/components/esp32_can/test.esp32-c6-idf.yaml index ac978482fc..c548b4f0f4 100644 --- a/tests/components/esp32_can/test.esp32-c6-idf.yaml +++ b/tests/components/esp32_can/test.esp32-c6-idf.yaml @@ -3,20 +3,20 @@ esphome: then: - canbus.send: # Extended ID explicit - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can use_extended_id: true can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] - canbus.send: # Standard ID by default - canbus_id: esp32_internal_can + canbus_id: esp32_can_esp32_internal_can can_id: 0x100 data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] # Note: esp32_internal_can_2 uses LISTENONLY mode, so no send actions canbus: - platform: esp32_can - id: esp32_internal_can + id: esp32_can_esp32_internal_can rx_pin: GPIO8 tx_pin: GPIO7 can_id: 4 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index bdc478ea03..f05735e8f4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: encryption: key: "0123456789abcdef0123456789abcdef" sensors: - - temp_sensor + - espnow_temp_sensor providers: - name: test-provider encryption: @@ -70,9 +70,9 @@ packet_transport: sensor: - platform: internal_temperature - id: temp_sensor + id: espnow_temp_sensor - platform: packet_transport provider: test-provider - remote_id: temp_sensor + remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index 8b1447a17a..a7ce7ed280 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_clockless - id: addr1 + id: fastled_clockless_addr1 chipset: WS2811 pin: 13 num_leds: 100 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_clockless_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/fastled_spi/common.yaml b/tests/components/fastled_spi/common.yaml index f6f7c5553b..19d00627f8 100644 --- a/tests/components/fastled_spi/common.yaml +++ b/tests/components/fastled_spi/common.yaml @@ -1,6 +1,6 @@ light: - platform: fastled_spi - id: addr1 + id: fastled_spi_addr1 chipset: WS2801 clock_pin: 22 data_pin: 23 @@ -59,13 +59,13 @@ light: name: Custom Effect sequence: - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 100% green: 100% blue: 0% - delay: 100ms - light.addressable_set: - id: addr1 + id: fastled_spi_addr1 red: 0% green: 100% blue: 0% diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index c156b4aea1..59063291e7 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -50,11 +50,11 @@ font: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: font_ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(monocraft), "Hello, World!"); it.print(0, 60, id(monocraft2), "Hello, World!"); diff --git a/tests/components/font/test.host.yaml b/tests/components/font/test.host.yaml index 387ea47335..8ada8b7a4e 100644 --- a/tests/components/font/test.host.yaml +++ b/tests/components/font/test.host.yaml @@ -8,7 +8,7 @@ font: id: roboto32 - file: "gfonts://Roboto" - id: roboto + id: font_roboto size: 20 glyphs: "0123456789." extras: @@ -44,12 +44,12 @@ font: display: - platform: sdl - id: sdl_display + id: font_sdl_display dimensions: width: 800 height: 600 lambda: |- - it.print(0, 0, id(roboto), "Hello, World!"); + it.print(0, 0, id(font_roboto), "Hello, World!"); it.print(0, 20, id(roboto_web), "Hello, World!"); it.print(0, 40, id(roboto_greek), "Hello κόσμε!"); it.print(0, 60, id(monocraft), "Hello, World!"); diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 11e2a16ca1..edf4493aa6 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -12,7 +12,7 @@ graph: display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: graph_ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 6cee2af232..50f8a5bc85 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,7 @@ display: - platform: ssd1306_i2c - id: ssd1306_i2c_display + i2c_id: i2c_bus + id: graphical_display_menu_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -10,12 +11,12 @@ display: font: - file: "gfonts://Roboto" - id: roboto + id: graphical_display_menu_roboto size: 20 number: - platform: template - id: test_number + id: graphical_display_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -31,13 +32,13 @@ select: switch: - platform: template - id: test_switch + id: graphical_display_menu_test_switch optimistic: true graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_i2c_display - font: roboto + display: graphical_display_menu_ssd1306_i2c_display + font: graphical_display_menu_roboto active: false mode: rotary on_enter: @@ -80,7 +81,7 @@ graphical_display_menu: lambda: 'ESP_LOGI("graphical_display_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: "Number" - number: test_number + number: graphical_display_menu_test_number on_enter: then: lambda: 'ESP_LOGI("graphical_display_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' @@ -97,7 +98,7 @@ graphical_display_menu: - display_menu.hide: test_graphical_display_menu - type: switch text: "Switch" - switch: test_switch + switch: graphical_display_menu_test_switch on_text: "Bright" off_text: "Dark" immediate_edit: false diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index ff464cda24..0fc40737f0 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: gt911_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_i2c_display + display: gt911_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 60e3defd49..71a7ac65c2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -93,12 +93,12 @@ text_sensor: event: - platform: template name: Test Event - id: test_event + id: homeassistant_test_event event_types: - test_event_type on_event: - homeassistant.event: - event: esphome.test_event + event: esphome.homeassistant_test_event data: event_name: !lambda |- return event_type; diff --git a/tests/components/image/test.esp32-idf.yaml b/tests/components/image/test.esp32-idf.yaml index aea2b4bbb0..9e93c4c289 100644 --- a/tests/components/image/test.esp32-idf.yaml +++ b/tests/components/image/test.esp32-idf.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 15 diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 2e7bfc5ae5..492b57c449 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 5 diff --git a/tests/components/image/test.rp2040-ard.yaml b/tests/components/image/test.rp2040-ard.yaml index 03a9c42a38..ce2a13fca7 100644 --- a/tests/components/image/test.rp2040-ard.yaml +++ b/tests/components/image/test.rp2040-ard.yaml @@ -3,7 +3,7 @@ packages: display: - platform: ili9xxx - id: main_lcd + id: image_main_lcd spi_id: spi_bus model: ili9342 cs_pin: 20 diff --git a/tests/components/infrared/common.yaml b/tests/components/infrared/common.yaml index cd2b10d31b..d9a4a43a26 100644 --- a/tests/components/infrared/common.yaml +++ b/tests/components/infrared/common.yaml @@ -23,7 +23,7 @@ infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: infrared_ir_rx name: "IR Receiver" remote_receiver_id: ir_receiver diff --git a/tests/components/integration/common-esp32.yaml b/tests/components/integration/common-esp32.yaml index 26550d3c5c..c912fb9b84 100644 --- a/tests/components/integration/common-esp32.yaml +++ b/tests/components/integration/common-esp32.yaml @@ -9,11 +9,11 @@ esphome: sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: ${pin} attenuation: 12db - platform: integration id: integration_sensor - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.esp8266-ard.yaml b/tests/components/integration/test.esp8266-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.esp8266-ard.yaml +++ b/tests/components/integration/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/integration/test.rp2040-ard.yaml b/tests/components/integration/test.rp2040-ard.yaml index 51d3e19077..377bad5578 100644 --- a/tests/components/integration/test.rp2040-ard.yaml +++ b/tests/components/integration/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ sensor: - platform: adc - id: my_sensor + id: integration_my_sensor pin: VCC - platform: integration - sensor: my_sensor + sensor: integration_my_sensor name: Integration Sensor time_unit: s diff --git a/tests/components/ir_rf_proxy/common-rx.yaml b/tests/components/ir_rf_proxy/common-rx.yaml index 37033a128e..7ced9a29b3 100644 --- a/tests/components/ir_rf_proxy/common-rx.yaml +++ b/tests/components/ir_rf_proxy/common-rx.yaml @@ -6,7 +6,7 @@ remote_receiver: infrared: # Infrared receiver - platform: ir_rf_proxy - id: ir_rx + id: ir_rf_proxy_ir_rx name: "IR Receiver" receiver_frequency: 38kHz remote_receiver_id: ir_receiver diff --git a/tests/components/lcd_gpio/common.yaml b/tests/components/lcd_gpio/common.yaml index bd842454a1..cebadcbf2c 100644 --- a/tests/components/lcd_gpio/common.yaml +++ b/tests/components/lcd_gpio/common.yaml @@ -1,6 +1,6 @@ display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_gpio_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} diff --git a/tests/components/lcd_menu/common.yaml b/tests/components/lcd_menu/common.yaml index 970c18e0d2..2287e812bd 100644 --- a/tests/components/lcd_menu/common.yaml +++ b/tests/components/lcd_menu/common.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: lcd_menu_test_number min_value: 0 step: 1 max_value: 10 @@ -22,7 +22,7 @@ switch: display: - platform: lcd_gpio - id: my_lcd_gpio + id: lcd_menu_my_lcd_gpio dimensions: 18x4 data_pins: - number: ${d0_pin} @@ -36,7 +36,7 @@ display: lcd_menu: id: test_lcd_menu - display_id: my_lcd_gpio + display_id: lcd_menu_my_lcd_gpio mark_back: 0x5e mark_selected: 0x3e mark_editing: 0x2a @@ -83,7 +83,7 @@ lcd_menu: lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' - type: number text: Number - number: test_number + number: lcd_menu_test_number on_enter: then: lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 2acc080c6d..71c00e5f10 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -156,7 +156,7 @@ light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 925197182c..49e49b4318 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 12 - platform: ledc id: test_ledc_1 diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 518011e925..1eb58eabc4 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 4 - platform: esp8266_pwm id: test_ledc_1 diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index cb421ed4bb..60521b8088 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -5,14 +5,14 @@ esphome: output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 light: - platform: binary id: test_binary_light name: Binary Light - output: test_binary + output: light_test_binary effects: - strobe: on_state: diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index a5a37fd559..21d5cad774 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -1,6 +1,6 @@ output: - platform: gpio - id: test_binary + id: light_test_binary pin: 0 - platform: rp2040_pwm id: test_ledc_1 diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 18f1ba10ae..5e71736eb0 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: lilygo_t5_47_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_i2c_display + display: lilygo_t5_47_ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 9ba7f34857..08001855cb 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -7,7 +7,7 @@ esphome: output: - platform: gpio - id: test_binary + id: lock_test_binary pin: 4 lock: @@ -32,4 +32,4 @@ lock: - platform: output name: Generic Output Lock id: test_lock2 - output: test_binary + output: lock_test_binary diff --git a/tests/components/monochromatic/common.yaml b/tests/components/monochromatic/common.yaml index 9915e086eb..e57c7bec29 100644 --- a/tests/components/monochromatic/common.yaml +++ b/tests/components/monochromatic/common.yaml @@ -1,13 +1,13 @@ output: - platform: ${light_platform} - id: light_output_1 + id: monochromatic_light_output_1 pin: ${pin} light: - platform: monochromatic name: Monochromatic Light id: monochromatic_light - output: light_output_1 + output: monochromatic_light_output_1 gamma_correct: 2.8 default_transition_length: 2s effects: diff --git a/tests/components/mpr121/common.yaml b/tests/components/mpr121/common.yaml index 67a06cf9c1..f96651e9bf 100644 --- a/tests/components/mpr121/common.yaml +++ b/tests/components/mpr121/common.yaml @@ -9,15 +9,15 @@ binary_sensor: name: touchkey0 channel: 0 - platform: mpr121 - id: bin1 + id: mpr121_bin1 name: touchkey1 channel: 1 - platform: mpr121 - id: bin2 + id: mpr121_bin2 name: touchkey2 channel: 2 - platform: mpr121 - id: bin3 + id: mpr121_bin3 name: touchkey3 channel: 6 diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 6af2ce3939..a1d27cdbd5 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -74,7 +74,7 @@ binary_sensor: state_topic: some/topic/binary_sensor qos: 2 lambda: |- - if (id(template_sens).state > 30) { + if (id(mqtt_template_sens).state > 30) { // Garage Door is open. return true; } @@ -105,8 +105,8 @@ button: climate: - platform: thermostat name: Test Thermostat - sensor: template_sens - humidity_sensor: template_sens + sensor: mqtt_template_sens + humidity_sensor: mqtt_template_sens action_state_topic: some/topicaction_state current_temperature_state_topic: some/topiccurrent_temperature_state current_humidity_state_topic: some/topiccurrent_humidity_state @@ -283,10 +283,10 @@ cover: datetime: - platform: template name: Date - id: test_date + id: mqtt_test_date type: date state_topic: some/topic/date - command_topic: test_date/custom_command_topic + command_topic: mqtt_test_date/custom_command_topic qos: 2 subscribe_qos: 2 set_action: @@ -300,7 +300,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: mqtt_test_time type: time state_topic: some/topic/time qos: 2 @@ -315,7 +315,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: mqtt_test_datetime type: datetime state_topic: some/topic/datetime qos: 2 @@ -407,7 +407,7 @@ select: sensor: - platform: template name: Template Sensor - id: template_sens + id: mqtt_template_sens lambda: |- if (id(some_binary_sensor).state) { return 42.0; @@ -423,13 +423,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(template_sens).state; + root["key"] = id(mqtt_template_sens).state; root["greeting"] = "Hello World"; switch: diff --git a/tests/components/mqtt_subscribe/common-ard.yaml b/tests/components/mqtt_subscribe/common-ard.yaml index 13ed311b17..6b0b16e500 100644 --- a/tests/components/mqtt_subscribe/common-ard.yaml +++ b/tests/components/mqtt_subscribe/common-ard.yaml @@ -18,13 +18,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/mqtt_subscribe/common-idf.yaml b/tests/components/mqtt_subscribe/common-idf.yaml index 070672f15c..0f5293ac61 100644 --- a/tests/components/mqtt_subscribe/common-idf.yaml +++ b/tests/components/mqtt_subscribe/common-idf.yaml @@ -19,13 +19,13 @@ sensor: - platform: mqtt_subscribe name: MQTT Subscribe Sensor topic: mqtt/topic - id: the_sensor + id: mqtt_subscribe_the_sensor qos: 2 on_value: - mqtt.publish_json: topic: the/topic payload: |- - root["key"] = id(the_sensor).state; + root["key"] = id(mqtt_subscribe_the_sensor).state; root["greeting"] = "Hello World"; text_sensor: diff --git a/tests/components/ntc/common.yaml b/tests/components/ntc/common.yaml index 79ae7f601d..1be2c335bc 100644 --- a/tests/components/ntc/common.yaml +++ b/tests/components/ntc/common.yaml @@ -1,23 +1,23 @@ sensor: - platform: adc - id: my_sensor + id: ntc_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: ntc_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: ntc_resist - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor calibration: b_constant: 3950 reference_resistance: 10k reference_temperature: 25°C - platform: ntc - sensor: resist + sensor: ntc_resist name: NTC Sensor2 calibration: - 10.0kOhm -> 25°C diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index c17c2dd5f8..b1a16ebfed 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Test Number" - id: test_number + id: number_test_number optimistic: true min_value: 0 max_value: 100 @@ -10,4 +10,4 @@ number: sensor: - platform: number name: "Test Number Value" - source_id: test_number + source_id: number_test_number diff --git a/tests/components/online_image/common-esp32.yaml b/tests/components/online_image/common-esp32.yaml index 32c909d351..ee4c1ed0b8 100644 --- a/tests/components/online_image/common-esp32.yaml +++ b/tests/components/online_image/common-esp32.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/common-esp8266.yaml b/tests/components/online_image/common-esp8266.yaml index d7722d171a..fc61aad92e 100644 --- a/tests/components/online_image/common-esp8266.yaml +++ b/tests/components/online_image/common-esp8266.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 15 dc_pin: 3 diff --git a/tests/components/online_image/common-rp2040.yaml b/tests/components/online_image/common-rp2040.yaml index bbb514bded..4d2785f3e8 100644 --- a/tests/components/online_image/common-rp2040.yaml +++ b/tests/components/online_image/common-rp2040.yaml @@ -6,7 +6,7 @@ packages: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 data_rate: 20MHz cs_pin: 20 diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml index 9116fd86e0..9972a673c0 100644 --- a/tests/components/online_image/test.esp32-s3-ard.yaml +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml index f219f71ee2..1f1485fd6c 100644 --- a/tests/components/online_image/test.esp32-s3-idf.yaml +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -8,7 +8,7 @@ http_request: display: - platform: ili9xxx spi_id: spi_bus - id: main_lcd + id: online_image_main_lcd model: ili9342 cs_pin: 20 dc_pin: 13 diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 81d802e9bf..df20dcde2b 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -1,19 +1,19 @@ esphome: on_boot: then: - - output.turn_off: light_output_1 - - output.turn_on: light_output_1 + - output.turn_off: output_light_output_1 + - output.turn_on: output_light_output_1 - output.set_level: - id: light_output_1 + id: output_light_output_1 level: 50% - output.set_min_power: - id: light_output_1 + id: output_light_output_1 min_power: 20% - output.set_max_power: - id: light_output_1 + id: output_light_output_1 max_power: 80% output: - platform: ${output_platform} - id: light_output_1 + id: output_light_output_1 pin: ${pin} diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 77a77fa3e4..aeda76d35c 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -9,7 +9,7 @@ pi4ioe5v6408: switch: - platform: gpio - id: switch1 + id: pi4ioe5v6408_switch1 pin: pi4ioe5v6408: pi4ioe1 number: 0 diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 262e75591e..320e5f775f 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -23,7 +23,7 @@ output: sensor: - platform: template - id: template_sensor1 + id: pid_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -35,8 +35,8 @@ climate: - platform: pid id: pid_climate name: PID Climate Controller - sensor: template_sensor1 - humidity_sensor: template_sensor1 + sensor: pid_template_sensor1 + humidity_sensor: pid_template_sensor1 default_target_temperature: 21°C heat_output: pid_slow_pwm control_parameters: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 7ff416dccb..951d8f7fc5 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -31,7 +31,7 @@ update: sensor: - platform: template - id: template_sensor1 + id: prometheus_template_sensor1 lambda: |- if (millis() > 10000) { return 42.0; @@ -91,7 +91,7 @@ binary_sensor: switch: - platform: template - id: template_switch1 + id: prometheus_template_switch1 lambda: |- if (millis() > 10000) { return true; @@ -185,7 +185,7 @@ climate: prometheus: include_internal: true relabel: - template_sensor1: + prometheus_template_sensor1: id: hellow_world name: Hello World template_text_sensor1: diff --git a/tests/components/qspi_dbi/common.yaml b/tests/components/qspi_dbi/common.yaml index 109db65b63..0eadfa7392 100644 --- a/tests/components/qspi_dbi/common.yaml +++ b/tests/components/qspi_dbi/common.yaml @@ -16,7 +16,7 @@ display: - platform: qspi_dbi model: CUSTOM - id: main_lcd + id: qspi_dbi_main_lcd draw_from_origin: true dimensions: height: 240 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c6c7049605..5631c48f95 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1,6 +1,6 @@ number: - platform: template - id: test_number + id: remote_transmitter_test_number optimistic: true min_value: 0 max_value: 255 @@ -151,7 +151,7 @@ button: on_press: remote_transmitter.transmit_raw: code: !lambda |- - return {(int32_t)id(test_number).state * 100, -1000}; + return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -253,7 +253,7 @@ button: destination_address: 0x5678 message_type: 0x01 data: !lambda |- - return {(uint8_t)id(test_number).state, 0x20, 0x30}; + return {(uint8_t)id(remote_transmitter_test_number).state, 0x20, 0x30}; - platform: template name: Digital Write on_press: diff --git a/tests/components/resistance/common.yaml b/tests/components/resistance/common.yaml index b3eec49548..8966b574df 100644 --- a/tests/components/resistance/common.yaml +++ b/tests/components/resistance/common.yaml @@ -1,11 +1,11 @@ sensor: - platform: adc - id: my_sensor + id: resistance_my_sensor pin: ${pin} - platform: resistance - sensor: my_sensor + sensor: resistance_my_sensor configuration: DOWNSTREAM resistor: 10kΩ reference_voltage: 3.3V name: Resistance - id: resist + id: resistance_resist diff --git a/tests/components/rgb/common.yaml b/tests/components/rgb/common.yaml index 9f25efa431..fb7b08eeb4 100644 --- a/tests/components/rgb/common.yaml +++ b/tests/components/rgb/common.yaml @@ -1,18 +1,18 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgb_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgb_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgb_light_output_3 pin: ${pin3} light: - platform: rgb name: RGB Light id: rgb_light - red: light_output_1 - green: light_output_2 - blue: light_output_3 + red: rgb_light_output_1 + green: rgb_light_output_2 + blue: rgb_light_output_3 diff --git a/tests/components/rgbct/common.yaml b/tests/components/rgbct/common.yaml index 65bb248e95..670f3ef9a4 100644 --- a/tests/components/rgbct/common.yaml +++ b/tests/components/rgbct/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbct_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbct_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbct_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbct_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbct_light_output_5 pin: ${pin5} light: - platform: rgbct name: RGBCT Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - color_temperature: light_output_4 - white_brightness: light_output_5 + red: rgbct_light_output_1 + green: rgbct_light_output_2 + blue: rgbct_light_output_3 + color_temperature: rgbct_light_output_4 + white_brightness: rgbct_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rgbw/common.yaml b/tests/components/rgbw/common.yaml index b0f44869d3..2b1ccae5a7 100644 --- a/tests/components/rgbw/common.yaml +++ b/tests/components/rgbw/common.yaml @@ -1,22 +1,22 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbw_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbw_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbw_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbw_light_output_4 pin: ${pin4} light: - platform: rgbw name: RGBW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - white: light_output_4 + red: rgbw_light_output_1 + green: rgbw_light_output_2 + blue: rgbw_light_output_3 + white: rgbw_light_output_4 color_interlock: true diff --git a/tests/components/rgbww/common.yaml b/tests/components/rgbww/common.yaml index 0013960c10..5baecaebb8 100644 --- a/tests/components/rgbww/common.yaml +++ b/tests/components/rgbww/common.yaml @@ -1,28 +1,28 @@ output: - platform: ${light_platform} - id: light_output_1 + id: rgbww_light_output_1 pin: ${pin1} - platform: ${light_platform} - id: light_output_2 + id: rgbww_light_output_2 pin: ${pin2} - platform: ${light_platform} - id: light_output_3 + id: rgbww_light_output_3 pin: ${pin3} - platform: ${light_platform} - id: light_output_4 + id: rgbww_light_output_4 pin: ${pin4} - platform: ${light_platform} - id: light_output_5 + id: rgbww_light_output_5 pin: ${pin5} light: - platform: rgbww name: RGBWW Light - red: light_output_1 - green: light_output_2 - blue: light_output_3 - cold_white: light_output_4 - warm_white: light_output_5 + red: rgbww_light_output_1 + green: rgbww_light_output_2 + blue: rgbww_light_output_3 + cold_white: rgbww_light_output_4 + warm_white: rgbww_light_output_5 cold_white_color_temperature: 153 mireds warm_white_color_temperature: 500 mireds color_interlock: true diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index b9b1436cdb..254ac0e13d 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -1,6 +1,6 @@ light: - platform: rp2040_pio_led_strip - id: led_strip + id: rp2040_pio_led_strip_led_strip pin: 4 num_leds: 60 pio: 0 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 45c039106f..2970a48afb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,7 +1,7 @@ output: - platform: rp2040_pwm - id: light_output_1 + id: rp2040_pwm_light_output_1 pin: 2 - platform: rp2040_pwm - id: light_output_2 + id: rp2040_pwm_light_output_2 pin: 3 diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index d3d3c9ee5e..3be86cf8be 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -3,7 +3,7 @@ host: display: - platform: sdl - id: sdl_display + id: sdl_sdl_display update_interval: 1s auto_clear_enabled: false show_test_card: true @@ -35,14 +35,14 @@ display: binary_sensor: - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_up key: SDLK_UP - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_down key: SDLK_DOWN - platform: sdl - sdl_id: sdl_display + sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 895f4b4b8f..96f459c53f 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -1,7 +1,7 @@ number: - platform: template name: "Speaker Number" - id: my_number + id: speaker_my_number optimistic: true min_value: 0 max_value: 100 @@ -46,7 +46,7 @@ button: - speaker.play: id: speaker_id data: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(speaker_my_number).state}; speaker: - platform: i2s_audio diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index d31b97553e..655e4241f1 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -13,7 +13,7 @@ speaker: - id: media_mixer_speaker_id audio_file: - - id: test_audio + - id: speaker_source_test_audio file: type: local path: $component_dir/test.wav diff --git a/tests/components/speed/common.yaml b/tests/components/speed/common.yaml index be8172af7e..70c91259ba 100644 --- a/tests/components/speed/common.yaml +++ b/tests/components/speed/common.yaml @@ -1,9 +1,9 @@ output: - platform: ${output_platform} - id: fan_output_1 + id: speed_fan_output_1 pin: ${pin} fan: - platform: speed - id: fan_speed - output: fan_output_1 + id: speed_fan_speed + output: speed_fan_output_1 diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index f099f77729..dbe109f524 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -34,7 +34,7 @@ esphome: switch: - platform: template - id: switch1 + id: sprinkler_switch1 optimistic: true - platform: template id: switch2 @@ -52,17 +52,17 @@ sprinkler: valves: - valve_switch: Yard Valve 0 enable_switch: Enable Yard Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 1 enable_switch: Enable Yard Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Yard Valve 2 enable_switch: Enable Yard Valve 2 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - id: garden_sprinkler_ctrlr @@ -73,11 +73,11 @@ sprinkler: valves: - valve_switch: Garden Valve 0 enable_switch: Enable Garden Valve 0 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 - valve_switch: Garden Valve 1 enable_switch: Enable Garden Valve 1 - pump_switch_id: switch1 + pump_switch_id: sprinkler_switch1 run_duration: 10s valve_switch_id: switch2 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index 09eb569a8e..b3b8ad85dc 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: ssd1306_i2c_display + id: ssd1306_i2c_ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index afdf26c150..3ea235cfb9 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -1,6 +1,6 @@ binary_sensor: - platform: switch - id: some_binary_sensor + id: switch_some_binary_sensor name: "Template Switch State" source_id: the_switch diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 659550cc01..a4a24d8da7 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -29,7 +29,7 @@ sx126x: number: - platform: template name: "SX126x Number" - id: my_number + id: sx126x_my_number optimistic: true min_value: 0 max_value: 100 @@ -47,4 +47,4 @@ button: - sx126x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index 6e48952fcc..b7eadc084f 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -29,7 +29,7 @@ sx127x: number: - platform: template name: "SX127x Number" - id: my_number + id: sx127x_my_number optimistic: true min_value: 0 max_value: 100 @@ -48,4 +48,4 @@ button: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index d3985a848b..92a1fc8eda 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -1,12 +1,12 @@ esphome: on_boot: - sensor.template.publish: - id: template_sens + id: template_template_sens state: 42.0 # Templated - sensor.template.publish: - id: template_sens + id: template_template_sens state: !lambda "return 42.0;" - water_heater.template.publish: @@ -28,34 +28,34 @@ esphome: # 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. - lambda: |- - id(template_sens).set_template([]() -> std::optional { + id(template_template_sens).set_template([]() -> std::optional { return 123.0f; }); # Test that esphome::optional alias still works for backward compatibility - lambda: |- - id(template_sens).set_template([]() -> esphome::optional { + id(template_template_sens).set_template([]() -> esphome::optional { return 42.0f; }); - datetime.date.set: - id: test_date + id: template_test_date date: year: 2021 month: 1 day: 1 - datetime.date.set: - id: test_date + id: template_test_date date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" - datetime.date.set: - id: test_date + id: template_test_date date: "2021-01-01" binary_sensor: - platform: template - id: some_binary_sensor + id: template_some_binary_sensor name: "Garage Door Open" lambda: |- - if (id(template_sens).state > 30) { + if (id(template_template_sens).state > 30) { // Garage Door is open. return true; } else { @@ -78,7 +78,7 @@ binary_sensor: name: "Garage Door Closed" condition: sensor.in_range: - id: template_sens + id: template_template_sens below: 30.0 filters: - invert: @@ -106,9 +106,9 @@ binary_sensor: sensor: - platform: template name: "Template Sensor" - id: template_sens + id: template_template_sens lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return 42.0; } return 0.0; @@ -230,7 +230,7 @@ switch: id: test_switch name: "Template Switch" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return true; } return false; @@ -249,7 +249,7 @@ cover: - platform: template name: "Template Cover" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -264,7 +264,7 @@ cover: name: "Template Cover with Triggers" id: template_cover_with_triggers lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return COVER_OPEN; } return COVER_CLOSED; @@ -442,7 +442,7 @@ lock: - platform: template name: "Template Lock" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return LOCK_STATE_LOCKED; } return LOCK_STATE_UNLOCKED; @@ -458,7 +458,7 @@ valve: id: template_valve name: "Template Valve" lambda: |- - if (id(some_binary_sensor).state) { + if (id(template_some_binary_sensor).state) { return VALVE_OPEN; } return VALVE_CLOSED; @@ -537,7 +537,7 @@ water_heater: datetime: - platform: template name: Date - id: test_date + id: template_test_date type: date initial_value: "2000-1-2" set_action: @@ -551,7 +551,7 @@ datetime: - x.day_of_month - platform: template name: Time - id: test_time + id: template_test_time type: time initial_value: "12:34:56am" set_action: @@ -565,7 +565,7 @@ datetime: - x.second - platform: template name: DateTime - id: test_datetime + id: template_test_datetime type: datetime initial_value: "2000-1-2 12:34:56" set_action: diff --git a/tests/components/tlc5947/common.yaml b/tests/components/tlc5947/common.yaml index 89588f3c76..f16f07503e 100644 --- a/tests/components/tlc5947/common.yaml +++ b/tests/components/tlc5947/common.yaml @@ -5,9 +5,9 @@ tlc5947: output: - platform: tlc5947 - id: output_1 + id: tlc5947_output_1 channel: 0 max_power: 0.8 - platform: tlc5947 - id: output_2 + id: tlc5947_output_2 channel: 1 diff --git a/tests/components/tlc5971/common.yaml b/tests/components/tlc5971/common.yaml index fe7fe25f0e..e372582ac1 100644 --- a/tests/components/tlc5971/common.yaml +++ b/tests/components/tlc5971/common.yaml @@ -4,9 +4,9 @@ tlc5971: output: - platform: tlc5971 - id: output_1 + id: tlc5971_output_1 channel: 0 max_power: 0.8 - platform: tlc5971 - id: output_2 + id: tlc5971_output_2 channel: 1 diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 56089aed1e..1f9249f1ba 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_i2c_display + id: tt21100_ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_i2c_display + display: tt21100_ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index fa76316b9c..c805188005 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -79,7 +79,7 @@ switch: number: - platform: template name: "Test Number" - id: test_number + id: uart_test_number optimistic: true min_value: 0 max_value: 100 @@ -103,7 +103,7 @@ button: - uart.write: id: uart_id data: !lambda |- - std::string cmd = "VALUE=" + str_sprintf("%.0f", id(test_number).state) + "\r\n"; + std::string cmd = "VALUE=" + str_sprintf("%.0f", id(uart_test_number).state) + "\r\n"; return std::vector(cmd.begin(), cmd.end()); event: diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index a40ca455cb..6824c5cca8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -24,7 +24,7 @@ udp: number: - platform: template name: "UDP Number" - id: my_number + id: udp_my_number optimistic: true min_value: 0 max_value: 100 @@ -38,4 +38,4 @@ button: - udp.write: data: [0x01, 0x02, 0x03] - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(my_number).state}; + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/ufire_ec/common.yaml b/tests/components/ufire_ec/common.yaml index 4260f0ab4c..2365b7a368 100644 --- a/tests/components/ufire_ec/common.yaml +++ b/tests/components/ufire_ec/common.yaml @@ -4,18 +4,18 @@ esphome: - ufire_ec.calibrate_probe: id: ufire_ec_board solution: 0.146 - temperature: !lambda "return id(test_sensor).state;" + temperature: !lambda "return id(ufire_ec_test_sensor).state;" - ufire_ec.reset: sensor: - platform: template - id: test_sensor + id: ufire_ec_test_sensor lambda: "return 21;" - platform: ufire_ec i2c_id: i2c_bus id: ufire_ec_board ec: name: Ufire EC - temperature_sensor: test_sensor + temperature_sensor: ufire_ec_test_sensor temperature_compensation: 20.0 temperature_coefficient: 0.019 diff --git a/tests/components/ufire_ise/common.yaml b/tests/components/ufire_ise/common.yaml index f7865ea87b..478c75ad37 100644 --- a/tests/components/ufire_ise/common.yaml +++ b/tests/components/ufire_ise/common.yaml @@ -11,11 +11,11 @@ esphome: sensor: - platform: template - id: test_sensor + id: ufire_ise_test_sensor lambda: "return 21;" - platform: ufire_ise i2c_id: i2c_bus id: ufire_ise_sensor - temperature_sensor: test_sensor + temperature_sensor: ufire_ise_test_sensor ph: name: Ufire pH diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml index b1885af266..cfba0060d9 100644 --- a/tests/components/web_server_idf/common.yaml +++ b/tests/components/web_server_idf/common.yaml @@ -12,7 +12,7 @@ network: sensor: - platform: template name: "Test Sensor" - id: test_sensor + id: web_server_idf_test_sensor update_interval: 60s lambda: "return 42.5;" @@ -25,5 +25,5 @@ binary_sensor: switch: - platform: template name: "Test Switch" - id: test_switch + id: web_server_idf_test_switch optimistic: true diff --git a/tests/components/wk2132_i2c/common.yaml b/tests/components/wk2132_i2c/common.yaml index 39013baeb2..93bb17b38f 100644 --- a/tests/components/wk2132_i2c/common.yaml +++ b/tests/components/wk2132_i2c/common.yaml @@ -16,4 +16,4 @@ wk2132_i2c: sensor: - platform: a02yyuw uart_id: wk2132_id_1 - id: distance_sensor + id: wk2132_i2c_distance_sensor diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index 18294974b9..5ff48bc64c 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -17,4 +17,4 @@ wk2132_spi: sensor: - platform: a02yyuw uart_id: wk2132_spi_uart1 - id: distance_sensor + id: wk2132_spi_distance_sensor diff --git a/tests/components/wk2168_i2c/common.yaml b/tests/components/wk2168_i2c/common.yaml index 49f0d1ec6b..1b2de74c02 100644 --- a/tests/components/wk2168_i2c/common.yaml +++ b/tests/components/wk2168_i2c/common.yaml @@ -23,7 +23,7 @@ wk2168_i2c: sensor: - platform: a02yyuw uart_id: wk2168_i2c_uart3 - id: distance_sensor + id: wk2168_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2168_spi/common.yaml b/tests/components/wk2168_spi/common.yaml index b402077aa3..a21a4a34d0 100644 --- a/tests/components/wk2168_spi/common.yaml +++ b/tests/components/wk2168_spi/common.yaml @@ -23,7 +23,7 @@ wk2168_spi: sensor: - platform: a02yyuw uart_id: wk2168_spi_uart3 - id: distance_sensor + id: wk2168_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2204_i2c/common.yaml b/tests/components/wk2204_i2c/common.yaml index 863633937b..55c67efd88 100644 --- a/tests/components/wk2204_i2c/common.yaml +++ b/tests/components/wk2204_i2c/common.yaml @@ -24,4 +24,4 @@ wk2204_i2c: sensor: - platform: a02yyuw uart_id: wk2204_id_3 - id: distance_sensor + id: wk2204_i2c_distance_sensor diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 0b62a7a009..ee00da22bb 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -25,4 +25,4 @@ wk2204_spi: sensor: - platform: a02yyuw uart_id: wk2204_spi_uart3 - id: distance_sensor + id: wk2204_spi_distance_sensor diff --git a/tests/components/wk2212_i2c/common.yaml b/tests/components/wk2212_i2c/common.yaml index a754bec5c7..d48063bb4d 100644 --- a/tests/components/wk2212_i2c/common.yaml +++ b/tests/components/wk2212_i2c/common.yaml @@ -19,7 +19,7 @@ wk2212_i2c: sensor: - platform: a02yyuw uart_id: uart_i2c_id1 - id: distance_sensor + id: wk2212_i2c_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/components/wk2212_spi/common.yaml b/tests/components/wk2212_spi/common.yaml index 969f16bb12..d17db2f676 100644 --- a/tests/components/wk2212_spi/common.yaml +++ b/tests/components/wk2212_spi/common.yaml @@ -17,7 +17,7 @@ wk2212_spi: sensor: - platform: a02yyuw uart_id: wk2212_spi_uart1 - id: distance_sensor + id: wk2212_spi_distance_sensor # individual binary_sensor inputs binary_sensor: diff --git a/tests/script/test_ci_check_duplicate_test_ids.py b/tests/script/test_ci_check_duplicate_test_ids.py new file mode 100644 index 0000000000..1ac8edeca0 --- /dev/null +++ b/tests/script/test_ci_check_duplicate_test_ids.py @@ -0,0 +1,114 @@ +"""Unit tests for script/ci_check_duplicate_test_ids.py. + +These lock in that the guard stays consistent with the actual config merge: it +prefixes substitutions the same way and delegates the conflict decision to +``merge_component_configs.deduplicate_by_id``. +""" + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import ci_check_duplicate_test_ids as checker # noqa: E402 + + +def _write_component(tests_dir: Path, name: str, body: str) -> None: + comp = tests_dir / name + comp.mkdir(parents=True) + (comp / "test.esp32-idf.yaml").write_text(body) + + +@pytest.fixture +def tests_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(checker, "TESTS_DIR", tmp_path) + return tmp_path + + +def test_substitution_only_difference_is_a_conflict(tests_dir: Path) -> None: + """Raw-identical items that differ only by a substitution still conflict. + + This is the class that the first version missed (and broke CI): the merge + prefixes ``${pin}`` per component, so the two become ``${a_pin}`` and + ``${b_pin}`` and collide. + """ + shared = "sensor:\n - platform: adc\n id: shared\n pin: ${pin}\n" + _write_component(tests_dir, "comp_a", shared) + _write_component(tests_dir, "comp_b", shared) + result = checker.scan() + assert any("shared" in line for line in result.conflicts), result.conflicts + + +def test_identical_substitution_free_items_do_not_conflict(tests_dir: Path) -> None: + same = "sensor:\n - platform: template\n id: shared\n name: Fixed\n" + _write_component(tests_dir, "comp_a", same) + _write_component(tests_dir, "comp_b", same) + assert checker.scan().conflicts == [] + + +def test_unique_ids_do_not_conflict(tests_dir: Path) -> None: + _write_component( + tests_dir, + "comp_a", + "sensor:\n - platform: adc\n id: comp_a_sensor\n pin: ${pin}\n", + ) + _write_component( + tests_dir, + "comp_b", + "sensor:\n - platform: adc\n id: comp_b_sensor\n pin: ${pin}\n", + ) + assert checker.scan().conflicts == [] + + +def test_same_list_key_under_different_paths_is_not_compared(tests_dir: Path) -> None: + """Ids sharing a list key name but under different parent paths don't conflict. + + The merge only concatenates lists at the same path, so ``foo.shared`` and + ``bar.shared`` are never compared against each other. + """ + _write_component( + tests_dir, "comp_a", "foo:\n shared:\n - id: dup\n v: 1\n" + ) + _write_component( + tests_dir, "comp_b", "bar:\n shared:\n - id: dup\n v: 2\n" + ) + assert checker.scan().conflicts == [] + + +def test_int_and_string_ids_are_distinct(tests_dir: Path) -> None: + """``5`` and ``"5"`` are different ids, exactly as deduplicate_by_id treats them.""" + _write_component(tests_dir, "comp_a", "sensor:\n - platform: t\n id: 5\n") + _write_component(tests_dir, "comp_b", 'sensor:\n - platform: t\n id: "5"\n') + assert checker.scan().conflicts == [] + + +def test_unparseable_fixture_is_reported_and_fails(tests_dir: Path) -> None: + """A fixture that cannot be parsed is surfaced and fails the run, not skipped.""" + _write_component(tests_dir, "broken", "foo: [unbalanced\n") + result = checker.scan() + assert result.conflicts == [] + assert any("broken" in path for path in result.parse_errors) + # The run as a whole must not pass when a covered fixture was not scanned. + assert checker.main() == 1 + + +def test_allowlisted_singleton_is_not_a_conflict(tests_dir: Path) -> None: + """Ids in INTENTIONALLY_SHARED_IDS may differ across components.""" + _write_component( + tests_dir, "comp_a", "time:\n - platform: sntp\n id: sntp_time\n" + ) + _write_component( + tests_dir, + "comp_b", + "time:\n - platform: sntp\n id: sntp_time\n servers: [a.example]\n", + ) + assert checker.scan().conflicts == [] + + +def test_empty_scan_fails(tests_dir: Path) -> None: + """A scan that covers zero fixtures is a false green and must fail.""" + result = checker.scan() + assert result.components_scanned == 0 + assert checker.main() == 1 diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py new file mode 100644 index 0000000000..6ed1bd2c1e --- /dev/null +++ b/tests/script/test_merge_component_configs.py @@ -0,0 +1,101 @@ +"""Unit tests for script/merge_component_configs.py deduplication.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to Python path so we can import the module +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import merge_component_configs # noqa: E402 + +deduplicate_by_id = merge_component_configs.deduplicate_by_id + + +def test_identical_duplicate_ids_collapse() -> None: + """Two identical items sharing an id collapse to one without error.""" + data = { + "sensor": [ + {"id": "shared", "platform": "template", "name": "A"}, + {"id": "shared", "platform": "template", "name": "A"}, + ] + } + result = deduplicate_by_id(data) + assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}] + + +def test_conflicting_duplicate_ids_raise() -> None: + """Two different items sharing an id is a hard error naming the id.""" + data = { + "sensor": [ + {"id": "dup", "platform": "template", "name": "A"}, + {"id": "dup", "platform": "template", "name": "B"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_intentionally_shared_id_does_not_raise() -> None: + """An allowlisted (section, id) may differ across components and collapse.""" + section, id_ = "time", "sntp_time" + assert (section, id_) in merge_component_configs.INTENTIONALLY_SHARED_IDS + data = { + section: [ + {"id": id_, "platform": "sntp"}, + {"id": id_, "platform": "sntp", "servers": ["a"]}, + ] + } + result = deduplicate_by_id(data) + # First occurrence wins, no error raised + assert result[section] == [{"id": id_, "platform": "sntp"}] + + +def test_allowlisted_id_in_other_section_still_raises() -> None: + """The allowlist is keyed on (section, id): the same id elsewhere conflicts.""" + data = { + "sensor": [ + {"id": "sntp_time", "platform": "a"}, + {"id": "sntp_time", "platform": "b"}, + ] + } + with pytest.raises(ValueError, match="sntp_time"): + deduplicate_by_id(data) + + +def test_items_without_id_are_preserved() -> None: + """Items lacking an id are passed through untouched.""" + data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]} + result = deduplicate_by_id(data) + assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}] + + +def test_comparison_is_type_sensitive() -> None: + """Comparison matches the merge exactly: 5 and "5" are a conflict. + + The duplicate-id CI guard reuses this function, so a looser (e.g. string + normalized) comparison would let the guard disagree with the build. + """ + data = { + "sensor": [ + {"id": "dup", "platform": "adc", "pin": 5}, + {"id": "dup", "platform": "adc", "pin": "5"}, + ] + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) + + +def test_nested_lists_are_checked() -> None: + """Conflicts nested inside dict values are also detected.""" + data = { + "wrapper": { + "sensor": [ + {"id": "dup", "value": 1}, + {"id": "dup", "value": 2}, + ] + } + } + with pytest.raises(ValueError, match="dup"): + deduplicate_by_id(data) From b21a69f07a341a7b1498e38afefe40510fc76c64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:08:39 +1200 Subject: [PATCH 259/282] Bump codecov/codecov-action from 6.0.1 to 7.0.0 (#16884) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3115b5b473..a57be34e9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From e0072ef4c546106295235b7377eeaed2f440ee03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:22:48 -0500 Subject: [PATCH 260/282] Bump tornado from 6.5.6 to 6.5.7 (#16883) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85d9857e7d..8202a2bb44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.6 +tornado==6.5.7 tzlocal==5.3.1 # from time tzdata>=2026.2 # from time pyserial==3.5 From 6e01f3fccd5e6f2b54d4038392b1e0eb54a13fac Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:29:35 -0400 Subject: [PATCH 261/282] [heatpumpir] Bump tonia/HeatpumpIR to 1.0.42 (#16880) --- .clang-tidy.hash | 2 +- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 25ae506732..591ca3eb4d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -58a760f5fd174bd438bcc3a7018292c158530c1a1d15181941c832d4c032511c +a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index aa3a08c294..cd1b7d2bb0 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,6 +126,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.41") + cg.add_library("tonia/HeatpumpIR", "1.0.42") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/platformio.ini b/platformio.ini index 182f426a31..251339fb5f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -81,7 +81,7 @@ lib_deps = heman/AsyncMqttClient-esphome@1.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -170,7 +170,7 @@ framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.5 ; wireguard - tonia/HeatpumpIR@1.0.41 ; heatpumpir + tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare From a32817207c76616f3dc01f004fe737676d154e48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:30:03 -0400 Subject: [PATCH 262/282] [ade7880] Fix reverse active energy reading from reserved register (#16822) --- esphome/components/ade7880/ade7880.cpp | 41 ++++++++----------- esphome/components/ade7880/ade7880.h | 3 +- .../components/ade7880/ade7880_registers.h | 4 +- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 9d19770c57..0f4189ad90 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -87,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_ sensor->publish_state(f(val)); } -template -void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) { - if (sensor == nullptr) { +void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) { + if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) { return; } - float val = this->read_s32_register16_(a_register); - sensor->publish_state(f(val)); + // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers + // accumulate signed energy since the last read (positive = imported/forward, negative = exported/ + // reverse), so split the value by sign into the forward and reverse running totals. + float val = this->read_s32_register16_(a_register) / 14400.0f; + if (val >= 0.0f) { + if (channel->forward_active_energy != nullptr) { + channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val); + } + } else { + if (channel->reverse_active_energy != nullptr) { + channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val); + } + } } void ADE7880::update() { @@ -117,12 +127,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, AWATTHR); } if (this->channel_b_ != nullptr) { @@ -133,12 +138,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, BPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, BWATTHR); } if (this->channel_c_ != nullptr) { @@ -149,12 +149,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, CPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, CWATTHR); } ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start); diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 69c8e5abba..53f501dee2 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -105,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent { // the callable will be passed a 'float' value and is expected to return a 'float' template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); - template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); + + void update_active_energy_(PowerChannel *channel, uint16_t a_register); void reset_device_(); diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h index aee4e42445..8b0b86fe7a 100644 --- a/esphome/components/ade7880/ade7880_registers.h +++ b/esphome/components/ade7880/ade7880_registers.h @@ -84,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402; constexpr uint16_t AFWATTHR = 0xE403; constexpr uint16_t BFWATTHR = 0xE404; constexpr uint16_t CFWATTHR = 0xE405; -constexpr uint16_t ARWATTHR = 0xE406; -constexpr uint16_t BRWATTHR = 0xE407; -constexpr uint16_t CRWATTHR = 0xE408; +// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation) constexpr uint16_t AFVARHR = 0xE409; constexpr uint16_t BFVARHR = 0xE40A; constexpr uint16_t CFVARHR = 0xE40B; From ddd21ba442f9d066834440fc92b92978db7c7330 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:06:13 +1000 Subject: [PATCH 263/282] [mipi_spi] add WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16 (#16887) --- esphome/components/mipi_spi/models/waveshare.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee8bd06700..ee46f931de 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -177,6 +177,20 @@ CO5300.extend( reset_pin=39, ) +# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) +# Pin assignments are the same as the 1.75" devkit: CS=12, RESET=39. Width/height set to 480x480. +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16", + width=480, + height=480, + pixel_mode="16bit", + offset_height=0, + offset_width=0, + cs_pin=12, + reset_pin=39, + data_rate="40MHz", +) + AXS15231.extend( "WAVESHARE-ESP32-S3-TOUCH-LCD-3.49", width=172, From cdc63f0fed7d91a1a94504c480b538d671c6e32d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 9 Jun 2026 08:33:15 +0200 Subject: [PATCH 264/282] [pcm5122] Add PCM5122 audio DAC component (#15709) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: kbx81 --- CODEOWNERS | 1 + esphome/components/pcm5122/__init__.py | 1 + esphome/components/pcm5122/audio_dac.py | 98 ++++++++++++ esphome/components/pcm5122/pcm5122.cpp | 140 ++++++++++++++++++ esphome/components/pcm5122/pcm5122.h | 72 +++++++++ esphome/components/pcm5122/pcm5122_gpio.cpp | 69 +++++++++ esphome/components/pcm5122/pcm5122_gpio.h | 29 ++++ tests/components/pcm5122/common.yaml | 24 +++ tests/components/pcm5122/test.esp32-ard.yaml | 4 + tests/components/pcm5122/test.esp32-idf.yaml | 4 + .../components/pcm5122/test.esp8266-ard.yaml | 4 + tests/components/pcm5122/test.rp2040-ard.yaml | 4 + 12 files changed, 450 insertions(+) create mode 100644 esphome/components/pcm5122/__init__.py create mode 100644 esphome/components/pcm5122/audio_dac.py create mode 100644 esphome/components/pcm5122/pcm5122.cpp create mode 100644 esphome/components/pcm5122/pcm5122.h create mode 100644 esphome/components/pcm5122/pcm5122_gpio.cpp create mode 100644 esphome/components/pcm5122/pcm5122_gpio.h create mode 100644 tests/components/pcm5122/common.yaml create mode 100644 tests/components/pcm5122/test.esp32-ard.yaml create mode 100644 tests/components/pcm5122/test.esp32-idf.yaml create mode 100644 tests/components/pcm5122/test.esp8266-ard.yaml create mode 100644 tests/components/pcm5122/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6a81cc1d40..c5beba8c0b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman +esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py new file mode 100644 index 0000000000..81e00ca74b --- /dev/null +++ b/esphome/components/pcm5122/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py new file mode 100644 index 0000000000..0017a1ef5a --- /dev/null +++ b/esphome/components/pcm5122/audio_dac.py @@ -0,0 +1,98 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +pcm5122_ns = cg.esphome_ns.namespace("pcm5122") +PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) +CONF_PCM5122 = "pcm5122" + +pcm5122_bits_per_sample = pcm5122_ns.enum("PCM5122BitsPerSample") +PCM5122_BITS_PER_SAMPLE_ENUM = { + 16: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_16, + 24: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_24, + 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +PCM5122GPIOPin = pcm5122_ns.class_( + "PCM5122GPIOPin", + cg.GPIOPin, + cg.Parented.template(PCM5122), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PCM5122), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x4D)) +) + + +def _validate_pin_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output, not both") + return value + + +def _validate_pin(value): + if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: + raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") + return value + + +PIN_SCHEMA = cv.All( + pins.gpio_base_schema( + PCM5122GPIOPin, + cv.int_range(min=3, max=6), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=_validate_pin_mode, + ).extend( + { + cv.Required(CONF_PCM5122): cv.use_id(PCM5122), + } + ), + _validate_pin, +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) +async def pcm5122_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCM5122]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp new file mode 100644 index 0000000000..68bbd50e4f --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -0,0 +1,140 @@ +#include "pcm5122.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122"; + +void PCM5122::setup() { + // Select page 0 and verify chip presence via I2C ACK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->status_set_error(LOG_STR("Write failed")); + this->mark_failed(); + return; + } + + // Reset audio modules + this->reg(PCM5122_REG_RESET) = PCM5122_RESET_MODULES; + delay(20); + this->reg(PCM5122_REG_RESET) = 0x00; + + // Ignore clock halt detection; enable clock divider autoset + optional err_detect = this->read_byte(PCM5122_REG_ERROR_DETECT); + if (!err_detect.has_value()) { + ESP_LOGE(TAG, "Failed to read ERROR_DETECT"); + this->mark_failed(); + return; + } + uint8_t err_detect_val = err_detect.value(); + err_detect_val |= PCM5122_ERROR_DETECT_IGNORE_CLKHALT; + err_detect_val &= ~PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET; + this->reg(PCM5122_REG_ERROR_DETECT) = err_detect_val; + + // I2S format with the configured word length + uint8_t alen; + switch (this->bits_per_sample_) { + case PCM5122_BITS_PER_SAMPLE_16: + alen = PCM5122_AUDIO_FORMAT_ALEN_16BIT; + break; + case PCM5122_BITS_PER_SAMPLE_24: + alen = PCM5122_AUDIO_FORMAT_ALEN_24BIT; + break; + case PCM5122_BITS_PER_SAMPLE_32: + default: + alen = PCM5122_AUDIO_FORMAT_ALEN_32BIT; + break; + } + this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + + // PLL reference clock: BCK + optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); + if (!pll_ref.has_value()) { + ESP_LOGE(TAG, "Failed to read PLL_REF"); + this->mark_failed(); + return; + } + uint8_t pll_ref_val = pll_ref.value(); + pll_ref_val &= ~PCM5122_PLL_REF_MASK; + pll_ref_val |= PCM5122_PLL_REF_SOURCE_BCK; + this->reg(PCM5122_REG_PLL_REF) = pll_ref_val; + + if (!this->set_mute_on() || !this->set_volume(this->volume_)) { + this->mark_failed(); + return; + } +} + +void PCM5122::dump_config() { + ESP_LOGCONFIG(TAG, "Audio DAC:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, + " Bits per sample: %u\n" + " Muted: %s", + this->bits_per_sample_, YESNO(this->is_muted_)); +} + +bool PCM5122::set_mute_off() { + this->is_muted_ = false; + return this->write_mute_(); +} + +bool PCM5122::set_mute_on() { + this->is_muted_ = true; + return this->write_mute_(); +} + +bool PCM5122::set_volume(float volume) { + this->volume_ = clamp(volume, 0.0f, 1.0f); + return this->write_volume_(); +} + +bool PCM5122::is_muted() { return this->is_muted_; } + +float PCM5122::volume() { return this->volume_; } + +bool PCM5122::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(PCM5122_REG_PAGE_SELECT, page)) { + this->current_page_ = -1; + return false; + } + this->current_page_ = page; + return true; +} + +bool PCM5122::write_mute_() { + uint8_t mute_byte = this->is_muted() ? 0x11 : 0x00; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_MUTE, mute_byte)) { + ESP_LOGE(TAG, "Writing mute failed"); + return false; + } + return true; +} + +bool PCM5122::write_volume_() { + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // Use set_mute_on() for silence. + const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale + const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + + const uint8_t volume_byte = + dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); + + ESP_LOGV(TAG, "Setting volume to 0x%.2x", volume_byte); + + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DVOL_LEFT, volume_byte) || + !this->write_byte(PCM5122_REG_DVOL_RIGHT, volume_byte)) { + ESP_LOGE(TAG, "Writing volume failed"); + return false; + } + return true; +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h new file mode 100644 index 0000000000..f86b096c82 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::pcm5122 { + +// Page 0 register addresses +static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; +static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_MUTE = 0x03; +static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; +static const uint8_t PCM5122_REG_PLL_REF = 0x0D; +static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; +static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; +static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; +static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 +static const uint8_t PCM5122_GPIO_OUTPUT_SELECT_REGISTER = 0x02; // GPIO driven by GPIO_OUTPUT register (reg 0x56) +static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; +static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; +static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; + +// Register values for init sequence +static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules +static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) +// ALEN (word length) occupies bits [1:0] of the audio format register +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_16BIT = 0x00; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_24BIT = 0x02; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_32BIT = 0x03; +static const uint8_t PCM5122_ERROR_DETECT_IGNORE_CLKHALT = (1 << 3); +static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); +static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] +static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) + +enum PCM5122BitsPerSample : uint8_t { + PCM5122_BITS_PER_SAMPLE_16 = 16, + PCM5122_BITS_PER_SAMPLE_24 = 24, + PCM5122_BITS_PER_SAMPLE_32 = 32, +}; + +class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + + bool set_mute_off() override; + bool set_mute_on() override; + bool set_volume(float volume) override; + + bool is_muted() override; + float volume() override; + + friend class PCM5122GPIOPin; + + protected: + bool select_page_(uint8_t page); + bool write_mute_(); + bool write_volume_(); + + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + bool is_muted_{false}; + PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; +}; + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp new file mode 100644 index 0000000000..1aef130457 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -0,0 +1,69 @@ +#include "pcm5122_gpio.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122.gpio"; + +void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCM5122GPIOPin::pin_mode(gpio::Flags flags) { + this->flags_ = flags; + if (!this->parent_->select_page_(0)) { + ESP_LOGE(TAG, "Failed to select page 0"); + return; + } + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_ENABLE); + if (!curr.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_ENABLE"); + return; + } + if (flags & gpio::FLAG_INPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() & ~(1 << (this->pin_ - 1)); + } else if (flags & gpio::FLAG_OUTPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() | (1 << (this->pin_ - 1)); + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT_SELECT + (this->pin_ - 1)) = PCM5122_GPIO_OUTPUT_SELECT_REGISTER; + optional invert = this->parent_->read_byte(PCM5122_REG_GPIO_INVERT); + if (!invert.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_INVERT"); + return; + } + if (this->inverted_) { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() & ~(1 << (this->pin_ - 1)); + } + } +} + +void PCM5122GPIOPin::digital_write(bool value) { + if (!this->parent_->select_page_(0)) + return; + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_OUTPUT); + if (!curr.has_value()) + return; + if (value) { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() & ~(1 << (this->pin_ - 1)); + } +} + +bool PCM5122GPIOPin::digital_read() { + if (!this->parent_->select_page_(0)) + return this->value_; + optional read = this->parent_->read_byte(PCM5122_REG_GPIO_INPUT); + if (read.has_value()) { + // GPIO input register has RSV at bit 0; GPIN_N is at bit N (unlike other GPIO registers) + this->value_ = !!(read.value() & (1 << this->pin_)) != this->inverted_; + } + return this->value_; +} + +size_t PCM5122GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "PCM5122 GPIO%u", this->pin_); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h new file mode 100644 index 0000000000..8edaa6d3e8 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/gpio.h" + +#include "pcm5122.h" + +namespace esphome::pcm5122 { + +class PCM5122GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{gpio::FLAG_NONE}; + bool value_{false}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml new file mode 100644 index 0000000000..cf96f57464 --- /dev/null +++ b/tests/components/pcm5122/common.yaml @@ -0,0 +1,24 @@ +audio_dac: + - platform: pcm5122 + id: pcm5122_dac + i2c_id: i2c_bus + address: 0x4D + bits_per_sample: 32bit + +output: + - platform: gpio + id: pcm5122_amp_enable + pin: + pcm5122: pcm5122_dac + number: 3 + mode: + output: true + +binary_sensor: + - platform: gpio + id: pcm5122_gpio_input + pin: + pcm5122: pcm5122_dac + number: 4 + mode: + input: true diff --git a/tests/components/pcm5122/test.esp32-ard.yaml b/tests/components/pcm5122/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/pcm5122/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp32-idf.yaml b/tests/components/pcm5122/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/pcm5122/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/pcm5122/test.esp8266-ard.yaml b/tests/components/pcm5122/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/pcm5122/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/pcm5122/test.rp2040-ard.yaml b/tests/components/pcm5122/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/pcm5122/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From 25d656d468ae8e51921d34f5a372951d93c66da1 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:04:10 +0200 Subject: [PATCH 265/282] [dsmr] Update dsmr_parser library to 1.9.0 (#16881) --- .clang-tidy.hash | 2 +- esphome/components/dsmr/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 591ca3eb4d..566cac066e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a1aa12cb72cb0cc57c25649aafed8412434b013885cfda107f8aac5c083b4577 +def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 05f9a78156..1dc3664602 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.8.0") + cg.add_library("esphome/dsmr_parser", "1.9.0") def final_validate(config: ConfigType) -> ConfigType: diff --git a/platformio.ini b/platformio.ini index 251339fb5f..b41e850bcd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.8.0 ; dsmr + esphome/dsmr_parser@1.9.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library From 5faed9d5f5284b9182d5e12af578ba91c38a395a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Jun 2026 13:04:51 +0200 Subject: [PATCH 266/282] [nrf52] native build - download toolchain and sdk in venv (#16388) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jonathan Swoboda --- esphome/components/nrf52/__init__.py | 13 + esphome/components/nrf52/framework.py | 171 ++++ esphome/const.py | 1 + esphome/core/__init__.py | 4 + esphome/espidf/framework.py | 597 +---------- esphome/framework_helpers.py | 677 +++++++++++++ requirements.txt | 1 + tests/unit_tests/test_core.py | 7 + tests/unit_tests/test_espidf_framework.py | 530 +++++++++- tests/unit_tests/test_framework_helpers.py | 954 ++++++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 219 ++++ tests/unit_tests/test_platformio_toolchain.py | 15 + 12 files changed, 2624 insertions(+), 565 deletions(-) create mode 100644 esphome/components/nrf52/framework.py create mode 100644 esphome/framework_helpers.py create mode 100644 tests/unit_tests/test_framework_helpers.py create mode 100644 tests/unit_tests/test_nrf52_framework.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 48b67e1ef9..56367d0b26 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -63,6 +63,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) +from .framework import check_and_install # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -562,3 +563,15 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) return False + + +def run_compile(args, config: ConfigType) -> bool: + if CORE.using_toolchain_platformio: + return False + if not CORE.using_toolchain_sdk_nrf: + raise EsphomeError( + "Unsupported toolchain for nRF52. " + "Supported toolchains are 'platformio' and 'sdk-nrf'." + ) + check_and_install() + raise EsphomeError("Native build for nRF52 is not implemented yet") diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py new file mode 100644 index 0000000000..607ad0c7ed --- /dev/null +++ b/esphome/components/nrf52/framework.py @@ -0,0 +1,171 @@ +import logging +import os +from pathlib import Path +import platform +import tempfile + +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + rmdir, + run_command_ok, + str_to_lst_of_str, +) + +_LOGGER = logging.getLogger(__name__) + +_WEST_VERSION = "1.5.0" +_TOOLCHAIN_VERSION = "0.17.4" + +SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/toolchain_{sysname}-{machine}_arm-zephyr-eabi.{extension}", + ) +) + + +def _get_tools_path() -> Path: + return CORE.data_dir / "sdk-nrf" + + +def _get_python_env_path(version: str) -> Path: + return _get_tools_path() / "penvs" / version + + +def _get_framework_path(version: str) -> Path: + return _get_tools_path() / "frameworks" / f"{version}" + + +def _get_toolchain_path(version: str) -> Path: + return _get_tools_path() / "toolchains" / f"{version}" + + +# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. +_SITECUSTOMIZE = """\ +import os, stat, shutil, sys +_orig = shutil.rmtree +def _handler(func, path, exc): + os.chmod(path, stat.S_IWRITE); func(path) +if sys.version_info >= (3, 12): + def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) +else: + def _rmtree(path, ignore_errors=False, onerror=None): + if onerror is None: + onerror = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +shutil.rmtree = _rmtree +""" + + +def _install_sitecustomize(python_env_path: Path) -> None: + """Patch shutil.rmtree inside the penv to handle read-only files. + + west init's shutil.move falls back to copytree+rmtree on Windows, and + rmtree dies on the read-only .idx/.pack files git just wrote into + manifest-tmp. Dropping a sitecustomize.py into the venv applies the + same fix esphome.helpers.rmtree uses, but inside the subprocess. + """ + if os.name != "nt": + return + site_packages = python_env_path / "Lib" / "site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + (site_packages / "sitecustomize.py").write_text(_SITECUSTOMIZE, encoding="utf-8") + + +def _get_toolchain_platform_info() -> tuple[str, str, str]: + """Return (sysname, machine, extension) for the current host.""" + extension = "tar.xz" + sysname = platform.system().lower() + machine = platform.machine() + if machine == "arm64": + machine = "aarch64" + if sysname == "darwin": + sysname = "macos" + elif sysname == "windows": + machine = "x86_64" + extension = "7z" + return sysname, machine, extension + + +def check_and_install() -> None: + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + python_env_path = _get_python_env_path(version) + env_python_path = get_python_env_executable_path(python_env_path, "python") + sentinel = python_env_path / ".ready" + install_venv = not sentinel.exists() + if install_venv: + rmdir(python_env_path, msg=f"Clean up {version} Python environment") + + create_venv(python_env_path, msg=f"{version}") + + _install_sitecustomize(python_env_path) + + _LOGGER.info("Installing west %s ...", _WEST_VERSION) + cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + if not run_command_ok(cmd): + raise EsphomeError(f"Install west for {version} Python environment failure") + sentinel.touch() + + framework_path = _get_framework_path(version) + sentinel = framework_path / ".ready" + if install_venv or not sentinel.exists(): + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "--mr", + f"{version}", + str(framework_path), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + sentinel.touch() + + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + sentinel = toolchains_dir / ".ready" + if not sentinel.exists(): + rmdir( + toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" + ) + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + + sysname, machine, extension = _get_toolchain_platform_info() + + download_from_mirrors( + SDK_NG_TOOLCHAIN_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + sentinel.touch() diff --git a/esphome/const.py b/esphome/const.py index 07f6bad771..22351244bd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -20,6 +20,7 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" + SDK_NRF = "sdk-nrf" class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index df8fd0a756..90c162fedd 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -867,6 +867,10 @@ class EsphomeCore: def using_toolchain_platformio(self): return self.toolchain == Toolchain.PLATFORMIO + @property + def using_toolchain_sdk_nrf(self): + return self.toolchain == Toolchain.SDK_NRF + @property def using_zephyr(self): return self.target_framework == "zephyr" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 2c520d0d2c..1bc79cc412 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1,8 +1,5 @@ """ESP-IDF framework tools for ESPHome.""" -from collections.abc import Iterable -from contextlib import ExitStack -import io import json import logging import os @@ -10,39 +7,29 @@ from pathlib import Path import platform import re import shutil -import subprocess -import sys import tempfile -from typing import IO - -import requests from esphome.config_validation import Version from esphome.core import CORE -from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed - -PathType = str | os.PathLike +from esphome.framework_helpers import ( + PathType, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) +from esphome.helpers import get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent -def _str_to_lst_of_str(a: str | list[str]) -> list[str]: - """ - Convert a string to a list of string - - Args: - a: A string containing semicolon-separated values, or an already-split list - - Returns: - list of strings - """ - if isinstance(a, list): - return a - return [f.strip() for f in a.split(";") if f.strip()] - - ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Cache-buster baked into the stamp file. Bump this whenever a change would @@ -54,23 +41,23 @@ ESPHOME_STAMP_FILE = ".esphome.stamp.json" # Bumping triggers a full reinstall on every user's next run. STAMP_SCHEMA_VERSION = "0" -ESPHOME_IDF_DEFAULT_TARGETS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TARGETS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") ) -ESPHOME_IDF_DEFAULT_TOOLS = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") ) -ESPHOME_IDF_DEFAULT_TOOLS_FORCE = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_TOOLS_FORCE = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS_FORCE", "required") ) -ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str( +ESPHOME_IDF_DEFAULT_FEATURES = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_FEATURES", "core") ) -ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( +ESPHOME_IDF_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS") or [ "https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz", @@ -78,7 +65,7 @@ ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str( ] ) -ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str( +ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( os.environ.get( "ESP_IDF_CONSTRAINTS_MIRRORS", "https://dl.espressif.com/dl/esp-idf/espidf.constraints.v{VERSION}.txt", @@ -124,59 +111,6 @@ def _get_python_env_path(version: str) -> Path: return _get_idf_tools_path() / "penvs" / f"{version}" -def rmdir(directory: PathType, msg: str | None = None): - """ - Remove a directory and its contents recursively if it exists. - - Args: - directory: Path to the directory to be removed - msg: Optional debug message to log before removal or it an error occurs - - Returns: - None - - Raises: - RuntimeError: If directory removal fails - """ - if Path(directory).is_dir(): - try: - if msg: - _LOGGER.debug(msg) - rmtree(directory) - except OSError as e: - raise RuntimeError( - f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" - ) from e - - -def _get_pythonexe_path() -> str: - """ - Get the path to the Python executable. - - Returns: - Path to Python executable as string - """ - # Try to get PYTHONEXEPATH environment variable - # Fallback to sys.executable if not set - return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) - - -def _get_python_env_executable_path(root: PathType, binary: str) -> Path: - """ - Get the path to a Python environment executable file. - - Args: - root: Root directory of the Python environment - binary: Name of the executable binary - - Returns: - Path object pointing to the executable file - """ - if os.name == "nt": - return Path(root) / "Scripts" / f"{binary}.exe" - return Path(root) / "bin" / binary - - def _check_stamp(file: PathType, data: dict[str, str]) -> bool: """ Check if a stamp file contains the expected data. @@ -210,84 +144,6 @@ def _write_stamp(file: PathType, data: dict[str, str]): json.dump(data, fp) -def _exec( - cmd: list[str], - msg: str | None = None, - env: dict[str, str] | None = None, - stream_output: bool = False, -) -> tuple[bool, str | None, str | None]: - """ - Execute a command and return results. - - Args: - cmd: list of command arguments - msg: Optional custom message for logging - env: Optional dictionary of environment variables to set - stream_output: If True, inherit parent stdio so the subprocess prints - directly to the terminal (useful for commands that produce their - own progress output). stdout/stderr are not captured in this mode. - - Returns: - tuple of (success: bool, stdout: str or None, stderr: str or None). - When stream_output is True, stdout and stderr are always None. - """ - cmd_str = msg or " ".join(cmd) - try: - _LOGGER.debug("%s - running ...", cmd_str) - - run_env = os.environ.copy() - if env: - run_env.update(env) - - if stream_output: - result = subprocess.run(cmd, check=False, env=run_env) - stdout = stderr = None - else: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - env=run_env, - ) - stdout = result.stdout - stderr = result.stderr - - if result.returncode != 0: - if stream_output: - _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) - else: - tail = (stderr or stdout or "").strip()[-1000:] - _LOGGER.error( - "%s - failed (returncode=%s). Tail:\n%s", - cmd_str, - result.returncode, - tail, - ) - return False, stdout, stderr - - _LOGGER.debug("%s - executed successfully", cmd_str) - return True, stdout, stderr - - except (subprocess.SubprocessError, OSError) as e: - _LOGGER.error("%s - error: %s", cmd_str, str(e)) - return False, None, None - - -def _exec_ok(*args, **kwargs) -> bool: - """ - Execute a command and return only the success status. - - Args: - *args: Positional arguments to pass to _exec function - **kwargs: Keyword arguments to pass to _exec function - - Returns: - True if command executed successfully, False otherwise - """ - return _exec(*args, **kwargs)[0] - - def _get_idf_version( idf_framework_root: PathType, env: dict[str, str] | None = None ) -> str: @@ -306,12 +162,12 @@ def _get_idf_version( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_version.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF version", env=(env or os.environ) @@ -346,12 +202,12 @@ def _get_idf_tool_paths( """ cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(_SCRIPTS_DIR / "get_idf_tool_paths.py"), str(idf_framework_root), ] - success, stdout, stderr = _exec( + success, stdout, stderr = run_command( cmd, msg="ESP-IDF tool paths", env=(env or os.environ) @@ -397,7 +253,7 @@ print(".".join([str(x) for x in sys.version_info])) """ cmd = [python_executable, "-c", script] - success, stdout, _ = _exec(cmd, msg="Python version", env=env) + success, stdout, _ = run_command(cmd, msg="Python version", env=env) if stdout: stdout = stdout.strip() @@ -406,393 +262,6 @@ print(".".join([str(x) for x in sys.version_info])) return stdout -def _create_venv(root: PathType, msg: str | None = None): - """ - Create a Python virtual environment. - - Args: - root: Path to the virtual environment directory - msg: Optional message for logging - - Returns: - None - - Raises: - Exception: If virtual environment creation fails - """ - cmd = [_get_pythonexe_path(), "-m", "venv", "--clear", root] - if not _exec_ok(cmd, msg=f"Create Python virtual environment for {msg}"): - raise RuntimeError(f"Can't create Python virtual environment for {msg}") - - -def _detect_archive_root(names: Iterable[str]) -> str | None: - """Detect a single top-level directory shared by all archive entries. - - Returns the directory name if every non-empty entry sits under the same - top-level directory, else ``None``. Extraction helpers use this to strip - the wrapper directory commonly found in source archives during extraction - rather than renaming it afterwards — post-extraction renames are - unreliable on Windows because antivirus and the search indexer briefly - hold handles on freshly written files. - """ - root: str | None = None - has_descendant = False - for raw in names: - name = raw.replace("\\", "/").strip("/") - if not name: - continue - first, sep, _ = name.partition("/") - if root is None: - root = first - elif root != first: - return None - if sep: - has_descendant = True - return root if has_descendant else None - - -def _tar_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a TAR archive to the specified directory. - - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. - - Args: - data: File-like object containing the TAR archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import stat - import tarfile - - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. - extract_dir = os.fspath(extract_dir) - abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 - - with tarfile.open(fileobj=data, mode="r") as tar_ref: - all_members = tar_ref.getmembers() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.name for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - safe_members = [] - - for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back - member.name = name - - safe_members.append(member) - - total = len(safe_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - for i, member in enumerate(safe_members, 1): - tar_ref.extract(member, abs_dest) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -def _zip_extract_all( - data: io.BufferedIOBase, - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract a ZIP archive to the specified directory. - - Args: - data: File-like object containing the ZIP archive - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - """ - import zipfile - - # See note in archive_extract_all_tar: os.path is used intentionally for - # the security-sensitive abspath/commonpath checks below. - extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 - - with zipfile.ZipFile(data, "r") as zip_ref: - all_members = zip_ref.infolist() - - # Detect a single common top-level directory and strip it during - # extraction so we don't have to flatten it via a rename afterwards. - strip_root = _detect_archive_root(m.filename for m in all_members) - strip_prefix = f"{strip_root}/" if strip_root is not None else None - - total = len(all_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) - - for i, member in enumerate(all_members, 1): - # 1. Normalize name - name = member.filename.lstrip("/\\") - - # 2. Reject absolute paths / Windows drives - if Path(name).is_absolute() or ( - os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 - ): - continue - - # 3. Strip wrapper directory if one was detected - if strip_prefix is not None: - norm = name.replace("\\", "/") - if norm in (strip_root, strip_prefix): - continue - if not norm.startswith(strip_prefix): - continue - name = norm[len(strip_prefix) :] - - # 4. Compute safe target path - target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 - - if os.path.commonpath([extract_dir, target_path]) != extract_dir: - raise ValueError(f"Unsafe path detected: {member.filename}") - - # 5. Assign sanitized name back - member.filename = name - - # 6. Extract - zip_ref.extract(member, extract_dir) - - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) - - -_ARCHIVE_MAGIC_MAP = { - b"\x1f\x8b\x08": _tar_extract_all, - b"\x42\x5a\x68": _tar_extract_all, - b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, - b"\x50\x4b\x03\x04": _zip_extract_all, -} - - -def archive_extract_all( - archive: PathType | io.RawIOBase | IO[bytes], - extract_dir: PathType = ".", - progress_header: str | None = None, -): - """ - Extract an archive file to the specified directory. - - Args: - archive: Path to archive file or file-like object - extract_dir: Directory to extract contents to - progress_header: If set, show a progress bar with this header - - Raises: - TypeError: If archive is not a valid type - ValueError: If archive format is unsupported - """ - - # 1. Handle different archive input types - with ExitStack() as stack: - archive_ref: io.BufferedIOBase - if isinstance(archive, (str, os.PathLike)): - archive_ref = stack.enter_context(Path(archive).open("rb")) - elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): - archive_ref = archive - elif isinstance(archive, io.RawIOBase): - archive_ref = io.BufferedReader(archive) - else: - raise TypeError( - f"archive must be str, Path, or file-like object: {type(archive)}" - ) - - # 2. Detect archive format and select appropriate extraction function - matched_fct = None - magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) - header = archive_ref.peek(magic_len) - for magic, fct in _ARCHIVE_MAGIC_MAP.items(): - if header.startswith(magic): - matched_fct = fct - break - if matched_fct is None: - raise ValueError("Unsupported archive format") - matched_fct(archive_ref, extract_dir, progress_header=progress_header) - - -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str | None: - """ - Download file from multiple mirrors with substitution support. - - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds - - Returns: - The source URL. - - Raises: - Exception: If all download attempts fail - """ - # 1. Open target file for writing if path given - with ExitStack() as stack: - if isinstance(target, (str, os.PathLike)): - f = stack.enter_context(Path(target).open("wb")) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - last_exception = None - - for mirror in mirrors: - # 3. Apply substitutions to URL - url = mirror.format(**substitutions) - - _LOGGER.debug("Trying downloading from %s", url) - - try: - # 4. Reset file pointer and download - f.seek(0) - f.truncate(0) - - with requests.get(url, stream=True, timeout=timeout) as r: - r.raise_for_status() - - total_size = int(r.headers.get("content-length", 0)) - downloaded = 0 - - progress = ProgressBar("Downloading") if total_size > 0 else None - - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - - downloaded += len(chunk) - - if progress is not None: - progress.update(downloaded / total_size) - - if progress is not None: - progress.update(1) - - _LOGGER.debug("Downloaded successfully from: %s", url) - - # 6. Reset file pointer and return - f.seek(0) - return url - - except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught - _LOGGER.debug("Failed to download %s: %s", url, str(e)) - last_exception = e - - # 7. Raise last exception if all mirrors failed - if last_exception: - raise last_exception - return None - - _GITHUB_SHORTHAND_RE = re.compile( r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" ) @@ -1067,12 +536,12 @@ def _check_esphome_idf_framework_install( if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "check", ] - if _exec_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): install = False # 4. Install framework tools if not installed or needs update @@ -1080,13 +549,13 @@ def _check_esphome_idf_framework_install( _LOGGER.info("Installing ESP-IDF %s framework ...", version) targets_str = ",".join(targets) cmd = [ - _get_pythonexe_path(), + get_system_python_path(), str(idf_tools_path), "--non-interactive", "install", f"--targets={targets_str}", ] + tools - if not _exec_ok( + if not run_command_ok( cmd, msg=f"ESP-IDF {version} framework installation", env=env, @@ -1128,7 +597,7 @@ def _check_esp_idf_python_env_install( framework_path = _get_framework_path(version) python_env_path = _get_python_env_path(version) env_stamp_file = python_env_path / ESPHOME_STAMP_FILE - env_python_path = _get_python_env_executable_path(python_env_path, "python") + env_python_path = get_python_env_executable_path(python_env_path, "python") _LOGGER.info("Checking ESP-IDF %s Python environment ...", version) install = force or not python_env_path.is_dir() or not env_python_path.is_file() @@ -1144,7 +613,7 @@ def _check_esp_idf_python_env_install( if install: rmdir(python_env_path, msg=f"Clean up ESP-IDF {version} Python environment") - _create_venv(python_env_path, msg=f"ESP-IDF {version}") + create_venv(python_env_path, msg=f"ESP-IDF {version}") esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( @@ -1174,7 +643,7 @@ def _check_esp_idf_python_env_install( "pip", "setuptools", ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Upgrade ESP-IDF {version} Python environment packages", env=env, @@ -1194,7 +663,7 @@ def _check_esp_idf_python_env_install( "-r", str(requirements_file), ] - if not _exec_ok( + if not run_command_ok( cmd, msg=f"Install ESP-IDF {version} Python dependencies for {feature}", env=env, @@ -1296,7 +765,7 @@ def get_framework_env( # 3. If Python environment path is provided, add it to PATH and set IDF_PYTHON_ENV_PATH if python_env_path: - python_path = _get_python_env_executable_path(python_env_path, "python") + python_path = get_python_env_executable_path(python_env_path, "python") path_list.insert(0, str(python_path.parent)) env["IDF_PYTHON_ENV_PATH"] = str(python_env_path) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py new file mode 100644 index 0000000000..276dfbbf1c --- /dev/null +++ b/esphome/framework_helpers.py @@ -0,0 +1,677 @@ +"""Generic toolchain installation helpers shared across framework implementations.""" + +from collections.abc import Iterable +from contextlib import ExitStack +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import IO + +import requests + +from esphome.helpers import ProgressBar, rmtree + +PathType = str | os.PathLike + +_LOGGER = logging.getLogger(__name__) + + +def str_to_lst_of_str(a: str | list[str]) -> list[str]: + """ + Convert a string to a list of string + + Args: + a: A string containing semicolon-separated values, or an already-split list + + Returns: + list of strings + """ + if isinstance(a, list): + return a + return [f.strip() for f in a.split(";") if f.strip()] + + +def rmdir(directory: PathType, msg: str | None = None): + """ + Remove a directory and its contents recursively if it exists. + + Args: + directory: Path to the directory to be removed + msg: Optional debug message to log before removal or it an error occurs + + Returns: + None + + Raises: + RuntimeError: If directory removal fails + """ + if Path(directory).is_dir(): + try: + if msg: + _LOGGER.debug(msg) + rmtree(directory) + except OSError as e: + raise RuntimeError( + f"Error during {msg}: can't remove `{directory}`. Please remove it manually!" + ) from e + + +def get_system_python_path() -> str: + """ + Get the path to the Python executable. + + Returns: + Path to Python executable as string + """ + # Try to get PYTHONEXEPATH environment variable + # Fallback to sys.executable if not set + return os.environ.get("PYTHONEXEPATH", os.path.normpath(sys.executable)) + + +def get_python_env_executable_path(root: PathType, binary: str) -> Path: + """ + Get the path to a Python environment executable file. + + Args: + root: Root directory of the Python environment + binary: Name of the executable binary + + Returns: + Path object pointing to the executable file + """ + if os.name == "nt": + return Path(root) / "Scripts" / f"{binary}.exe" + return Path(root) / "bin" / binary + + +def run_command( + cmd: list[str], + msg: str | None = None, + env: dict[str, str] | None = None, + stream_output: bool = False, + cwd: PathType | None = None, +) -> tuple[bool, str | None, str | None]: + """ + Execute a command and return results. + + Args: + cmd: list of command arguments + msg: Optional custom message for logging + env: Optional dictionary of environment variables to set + stream_output: If True, inherit parent stdio so the subprocess prints + directly to the terminal (useful for commands that produce their + own progress output). stdout/stderr are not captured in this mode. + cwd: Optional working directory for the subprocess. + + Returns: + tuple of (success: bool, stdout: str or None, stderr: str or None). + When stream_output is True, stdout and stderr are always None. + """ + cmd_str = msg or " ".join(cmd) + try: + _LOGGER.debug("%s - running ...", cmd_str) + + run_env = os.environ.copy() + if env: + run_env.update(env) + + if stream_output: + result = subprocess.run(cmd, check=False, env=run_env, cwd=cwd) + stdout = stderr = None + else: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + env=run_env, + cwd=cwd, + ) + stdout = result.stdout + stderr = result.stderr + + if result.returncode != 0: + if stream_output: + _LOGGER.error("%s - failed (returncode=%s)", cmd_str, result.returncode) + else: + tail = (stderr or stdout or "").strip()[-1000:] + _LOGGER.error( + "%s - failed (returncode=%s). Tail:\n%s", + cmd_str, + result.returncode, + tail, + ) + return False, stdout, stderr + + _LOGGER.debug("%s - executed successfully", cmd_str) + return True, stdout, stderr + + except (subprocess.SubprocessError, OSError) as e: + _LOGGER.error("%s - error: %s", cmd_str, str(e)) + return False, None, None + + +def run_command_ok(*args, **kwargs) -> bool: + """ + Execute a command and return only the success status. + + Args: + *args: Positional arguments to pass to run_command + **kwargs: Keyword arguments to pass to run_command + + Returns: + True if command executed successfully, False otherwise + """ + return run_command(*args, **kwargs)[0] + + +def create_venv(root: PathType, msg: str | None = None): + """ + Create a Python virtual environment. + + Args: + root: Path to the virtual environment directory + msg: Optional message for logging + + Returns: + None + + Raises: + RuntimeError: If virtual environment creation fails + """ + cmd = [get_system_python_path(), "-m", "venv", "--clear", root] + if not run_command_ok(cmd, msg=f"Create Python virtual environment for {msg}"): + raise RuntimeError(f"Can't create Python virtual environment for {msg}") + + +def _detect_archive_root(names: Iterable[str]) -> str | None: + """Detect a single top-level directory shared by all archive entries. + + Returns the directory name if every non-empty entry sits under the same + top-level directory, else ``None``. Extraction helpers use this to strip + the wrapper directory commonly found in source archives during extraction + rather than renaming it afterwards — post-extraction renames are + unreliable on Windows because antivirus and the search indexer briefly + hold handles on freshly written files. + """ + root: str | None = None + has_descendant = False + for raw in names: + name = raw.replace("\\", "/").strip("/") + if not name: + continue + first, sep, _ = name.partition("/") + if root is None: + root = first + elif root != first: + return None + if sep: + has_descendant = True + return root if has_descendant else None + + +def _tar_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a TAR archive to the specified directory. + + Implementation is inspired by Python 3.12's tarfile data filtering logic. + This can be replaced with the standard library implementation once + support for Python 3.11 is no longer required. + + Args: + data: File-like object containing the TAR archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import stat + import tarfile + + # Tar extraction safety: os.path.realpath / commonpath / normpath have no + # pathlib equivalents and Path.resolve() would follow symlinks unsafely. + # Use os.path for the security-sensitive parts; the simple checks move to + # Path. + extract_dir = os.fspath(extract_dir) + abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 + + with tarfile.open(fileobj=data, mode="r") as tar_ref: + all_members = tar_ref.getmembers() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.name for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + safe_members = [] + + for member in all_members: + name = member.name + + # 1. Strip leading slashes + name = name.lstrip("/" + os.sep) + + # 2. Reject absolute paths (incl. Windows drive) + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute final path + target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 + if os.path.commonpath([abs_dest, target_path]) != abs_dest: + continue + + # 5. Validate links properly + if member.issym() or member.islnk(): + linkname = member.linkname + + # Reject absolute link targets + if Path(linkname).is_absolute(): + continue + + if member.islnk() and strip_prefix is not None: + # Hard-link linknames reference another archive member + # by its archive name. We've stripped the wrapper prefix + # from member.name above (step 3); strip it here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative + # paths, not archive-member references, so they don't + # need this treatment. + norm_link = linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + linkname = norm_link[len(strip_prefix) :] + + # Strip leading slashes + linkname = os.path.normpath(linkname) + + if member.issym(): + link_target = os.path.join( # noqa: PTH118 + abs_dest, + os.path.dirname(name), # noqa: PTH120 + linkname, + ) + else: + link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 + link_target = os.path.realpath(link_target) + + if os.path.commonpath([abs_dest, link_target]) != abs_dest: + continue + + # write back normalized linkname + member.linkname = linkname + + # 6. Sanitize permissions + mode = member.mode + if mode is not None: + # Strip high bits & group/other write bits + mode &= ( + stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) + if member.isfile() or member.islnk(): + # remove exec bits unless explicitly user-executable + if not (mode & stat.S_IXUSR): + mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + mode |= stat.S_IRUSR | stat.S_IWUSR + elif not (member.isdir() or member.issym()): + # Block special files. Directories and symlinks keep + # their masked-original mode — passing None here would + # crash tarfile.extract on Python <3.12 (its chmod + # path calls os.chmod unconditionally). + continue + + member.mode = mode + + # 7. Strip ownership + member.uid = None + member.gid = None + member.uname = None + member.gname = None + + # 8. Assign sanitized name back + member.name = name + + safe_members.append(member) + + total = len(safe_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + for i, member in enumerate(safe_members, 1): + tar_ref.extract(member, abs_dest) + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _zip_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a ZIP archive to the specified directory. + + Args: + data: File-like object containing the ZIP archive + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import zipfile + + # See note in _tar_extract_all: os.path is used intentionally for + # the security-sensitive abspath/commonpath checks below. + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + + with zipfile.ZipFile(data, "r") as zip_ref: + all_members = zip_ref.infolist() + + # Detect a single common top-level directory and strip it during + # extraction so we don't have to flatten it via a rename afterwards. + strip_root = _detect_archive_root(m.filename for m in all_members) + strip_prefix = f"{strip_root}/" if strip_root is not None else None + + total = len(all_members) + progress = ( + ProgressBar(progress_header) if progress_header and total > 0 else None + ) + + for i, member in enumerate(all_members, 1): + # 1. Normalize name + name = member.filename.lstrip("/\\") + + # 2. Reject absolute paths / Windows drives + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + + # 3. Strip wrapper directory if one was detected + if strip_prefix is not None: + norm = name.replace("\\", "/") + if norm in (strip_root, strip_prefix): + continue + if not norm.startswith(strip_prefix): + continue + name = norm[len(strip_prefix) :] + + # 4. Compute safe target path + target_path = os.path.abspath(os.path.join(extract_dir, name)) # noqa: PTH100, PTH118 + + if os.path.commonpath([extract_dir, target_path]) != extract_dir: + raise ValueError(f"Unsafe path detected: {member.filename}") + + # 5. Assign sanitized name back + member.filename = name + + # 6. Extract + zip_ref.extract(member, extract_dir) + + if progress is not None: + progress.update(i / total) + if progress is not None: + progress.update(1) + + +def _rename_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: + """Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations. + + Antivirus/indexer handles on freshly-written files can briefly block + ``os.rename`` with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED. The + handle is released within tens of ms in practice, so exponential backoff + works. + """ + for i in range(attempts): + try: + src.rename(dst) + return + except PermissionError: + if i == attempts - 1: + raise + time.sleep(0.1 * (2**i)) + + +def _7z_extract_all( + data: io.BufferedIOBase, + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract a 7z archive to the specified directory. + + py7zr only supports bulk extraction (no per-member rename hook like + tarfile/zipfile), so we extract into a unique staging subdir of + ``extract_dir`` and then move children up. This keeps everything on + the same volume and sidesteps wrapper-vs-child name collisions + (e.g. ``arm-zephyr-eabi/`` containing another ``arm-zephyr-eabi/``). + + Args: + data: File-like object containing the 7z archive (must be seekable) + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + """ + import py7zr + + extract_dir = os.path.abspath(extract_dir) # noqa: PTH100 + Path(extract_dir).mkdir(parents=True, exist_ok=True) + + suffix = 0 + while True: + staging = Path(extract_dir) / f".extract_tmp_{suffix}" + if not staging.exists(): + break + suffix += 1 + staging.mkdir() + + try: + with py7zr.SevenZipFile(data, "r") as z: + all_names = z.getnames() + + # Detect a single common top-level directory to flatten. + strip_root = _detect_archive_root(all_names) + + # Validate names: reject absolute paths, Windows drives, and + # path traversal. Filter via targets= since py7zr can't rename + # per-member. + safe_targets: list[str] = [] + for raw in all_names: + name = raw.lstrip("/\\") + if not name: + continue + if Path(name).is_absolute() or ( + os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 + ): + continue + target_path = os.path.abspath(os.path.join(staging, name)) # noqa: PTH100, PTH118 + if os.path.commonpath([str(staging), target_path]) != str(staging): + continue + safe_targets.append(raw) + + progress = ( + ProgressBar(progress_header) + if progress_header and safe_targets + else None + ) + + if len(safe_targets) == len(all_names): + z.extractall(path=staging) + else: + z.extract(path=staging, targets=safe_targets) + + if progress is not None: + progress.update(1) + + src_root = staging / strip_root if strip_root else staging + for item in src_root.iterdir(): + dest = Path(extract_dir) / item.name + if dest.exists(): + if dest.is_dir(): + rmtree(dest) + else: + dest.unlink() + _rename_with_retry(item, dest) + finally: + # staging is created before the try, so it always exists here; the + # guard is defensive cleanup and its False branch is unreachable. + if staging.exists(): # pragma: no cover + rmtree(staging) + + +_ARCHIVE_MAGIC_MAP = { + b"\x1f\x8b\x08": _tar_extract_all, + b"\x42\x5a\x68": _tar_extract_all, + b"\xfd\x37\x7a\x58\x5a\x00": _tar_extract_all, + b"\x50\x4b\x03\x04": _zip_extract_all, + b"\x37\x7a\xbc\xaf\x27\x1c": _7z_extract_all, +} + + +def archive_extract_all( + archive: PathType | io.RawIOBase | IO[bytes], + extract_dir: PathType = ".", + progress_header: str | None = None, +): + """ + Extract an archive file to the specified directory. + + Args: + archive: Path to archive file or file-like object + extract_dir: Directory to extract contents to + progress_header: If set, show a progress bar with this header + + Raises: + TypeError: If archive is not a valid type + ValueError: If archive format is unsupported + """ + + # 1. Handle different archive input types + with ExitStack() as stack: + archive_ref: io.BufferedIOBase + if isinstance(archive, (str, os.PathLike)): + archive_ref = stack.enter_context(Path(archive).open("rb")) + elif isinstance(archive, (io.BufferedReader, io.BufferedRandom)): + archive_ref = archive + elif isinstance(archive, io.RawIOBase): + archive_ref = io.BufferedReader(archive) + else: + raise TypeError( + f"archive must be str, Path, or file-like object: {type(archive)}" + ) + + # 2. Detect archive format and select appropriate extraction function + matched_fct = None + magic_len = max(len(k) for k in _ARCHIVE_MAGIC_MAP) + header = archive_ref.peek(magic_len) + for magic, fct in _ARCHIVE_MAGIC_MAP.items(): + if header.startswith(magic): + matched_fct = fct + break + if matched_fct is None: + raise ValueError("Unsupported archive format") + matched_fct(archive_ref, extract_dir, progress_header=progress_header) + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Raises: + ValueError: If mirrors list is empty. + Exception: If all download attempts fail. + """ + # 1. Open target file for writing if path given + with ExitStack() as stack: + if isinstance(target, (str, os.PathLike)): + f = stack.enter_context(Path(target).open("wb")) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" + ) + + # 2. Try each mirror in order + last_exception = None + + for mirror in mirrors: + # 3. Apply substitutions to URL + url = mirror.format(**substitutions) + + _LOGGER.debug("Trying downloading from %s", url) + + try: + # 4. Reset file pointer and download + f.seek(0) + f.truncate(0) + + with requests.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + + total_size = int(r.headers.get("content-length", 0)) + downloaded = 0 + + progress = ProgressBar("Downloading") if total_size > 0 else None + + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + downloaded += len(chunk) + + if progress is not None: + progress.update(downloaded / total_size) + + if progress is not None: + progress.update(1) + + _LOGGER.debug("Downloaded successfully from: %s", url) + + # 6. Reset file pointer and return + f.seek(0) + return url + + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + _LOGGER.debug("Failed to download %s: %s", url, str(e)) + last_exception = e + + # 7. Raise last exception if all mirrors failed + if last_exception: + raise last_exception + raise ValueError("download_from_mirrors called with an empty mirrors list") diff --git a/requirements.txt b/requirements.txt index 8202a2bb44..ed7f2c2941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 +py7zr==0.22.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 2322fdd014..cc371ee1f9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -894,6 +894,13 @@ class TestEsphomeCore: "foo/build/.pioenvs/test-device/bootloader.bin" ) + def test_using_toolchain_sdk_nrf(self, target): + """using_toolchain_sdk_nrf is True only for the SDK_NRF toolchain.""" + target.toolchain = const.Toolchain.SDK_NRF + assert target.using_toolchain_sdk_nrf is True + target.toolchain = const.Toolchain.ESP_IDF + assert target.using_toolchain_sdk_nrf is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 9f4e4fcca8..036c7c0454 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,12 +2,32 @@ # pylint: disable=protected-access +import io +import json from pathlib import Path +import tarfile +from types import SimpleNamespace from unittest.mock import patch import pytest -from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source +from esphome.espidf.framework import ( + _check_stamp, + _clone_idf_with_submodules, + _get_framework_path, + _get_idf_tool_paths, + _get_idf_tools_path, + _get_idf_version, + _get_python_env_path, + _get_python_version, + _parse_git_source, + _patch_tools_json_for_linux_arm64, + _write_idf_version_txt, + _write_stamp, + check_esp_idf_install, + get_framework_env, +) +from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @pytest.mark.parametrize( @@ -154,3 +174,511 @@ def test_clone_idf_with_submodules_raises_when_tree_missing( "https://github.com/espressif/esp-idf.git", None, ) + + +# --------------------------------------------------------------------------- +# Helpers for _tar_extract_all hard-link prefix-stripping tests +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], file_contents: dict[str, bytes] +) -> io.BytesIO: + """Build an in-memory tar archive from a list of TarInfo objects.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in file_contents: + data = file_contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _regular(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _hardlink(name: str, linkname: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = linkname + info.size = 0 + info.mode = 0o644 + return info + + +class TestTarExtractHardLinkPrefixStripping: + """ + Covers the hard-link prefix-stripping block in _tar_extract_all (L528-541). + + Archive layout used by every test: + + wrapper/ ← single top-level wrapper dir (stripped) + wrapper/target.txt ← regular file; becomes target.txt in dest + wrapper/link_good ← hard link to wrapper/target.txt (kept, linkname stripped) + wrapper/link_exact_root ← hard link to "wrapper" (skipped – equals strip_root) + wrapper/link_exact_prefix ← hard link to "wrapper/" (skipped – equals strip_prefix) + wrapper/link_outside ← hard link to "other/target.txt" (skipped – not under prefix) + """ + + WRAPPER = "wrapper" + + def _build_archive(self) -> io.BytesIO: + members = [ + _regular(f"{self.WRAPPER}/"), + _regular(f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_good", f"{self.WRAPPER}/target.txt"), + _hardlink(f"{self.WRAPPER}/link_exact_root", self.WRAPPER), + _hardlink(f"{self.WRAPPER}/link_exact_prefix", f"{self.WRAPPER}/"), + _hardlink(f"{self.WRAPPER}/link_outside", "other/target.txt"), + ] + return _make_tar(members, {f"{self.WRAPPER}/target.txt": b"hello"}) + + def test_good_hardlink_is_extracted_with_stripped_linkname( + self, tmp_path: Path + ) -> None: + """Hard link whose linkname starts with wrapper/ is extracted and its + linkname has the prefix removed so tarfile can resolve the target.""" + _tar_extract_all(self._build_archive(), tmp_path) + link = tmp_path / "link_good" + assert link.exists(), "link_good should have been extracted" + assert link.read_bytes() == b"hello" + + def test_hardlink_equal_to_strip_root_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root exactly must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_root").exists() + + def test_hardlink_equal_to_strip_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_prefix (strip_root + '/') must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_exact_prefix").exists() + + def test_hardlink_outside_prefix_is_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname does not start with wrapper/ must be dropped.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert not (tmp_path / "link_outside").exists() + + def test_regular_file_and_no_spurious_files(self, tmp_path: Path) -> None: + """Sanity check: target.txt is extracted and no unexpected files appear.""" + _tar_extract_all(self._build_archive(), tmp_path) + assert (tmp_path / "target.txt").read_bytes() == b"hello" + extracted = {p.name for p in tmp_path.iterdir()} + assert extracted == {"target.txt", "link_good"} + + +_IDF_VERSION = "5.1.2" + + +@pytest.fixture +def espidf_mocks(setup_core: Path): + """Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir.""" + # archive_extract_all is mocked, so pre-create the framework dir that the + # extracted-marker touch writes into. + _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) + with ( + patch("esphome.espidf.framework.rmdir"), + patch( + "esphome.espidf.framework.download_from_mirrors", + return_value="https://example.com/idf.tar.xz", + ) as download, + patch("esphome.espidf.framework.archive_extract_all") as extract, + patch("esphome.espidf.framework.create_venv") as venv, + patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, + patch("esphome.espidf.framework._write_idf_version_txt"), + patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), + patch("esphome.espidf.framework._write_stamp"), + patch("esphome.espidf.framework._check_stamp", return_value=True), + patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), + patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + ): + yield SimpleNamespace( + download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + ) + + +def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: + """A forced install drives download/extract, venv creation, and pip installs.""" + framework_path, python_env_path = check_esp_idf_install(_IDF_VERSION, force=True) + + assert framework_path == _get_framework_path(_IDF_VERSION) + assert python_env_path == _get_python_env_path(_IDF_VERSION) + # framework tarball + python-env constraints file are both downloaded + assert espidf_mocks.download.call_count == 2 + espidf_mocks.extract.assert_called_once() + espidf_mocks.venv.assert_called_once() + espidf_mocks.clone.assert_not_called() + + +def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: + """A git source_url clones instead of downloading; explicit tools skip discovery.""" + check_esp_idf_install( + _IDF_VERSION, + force=True, + source_url="https://github.com/espressif/esp-idf.git", + tools=["xtensa-esp-elf"], + ) + + espidf_mocks.clone.assert_called_once() + # framework is cloned, so only the python-env constraints file is downloaded + assert espidf_mocks.download.call_count == 1 + + +def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace) -> None: + """Marker + matching stamps + existing python env → nothing is re-installed.""" + framework_path = _get_framework_path(_IDF_VERSION) + (framework_path / ".esphome_extracted").touch() + python_env_path = _get_python_env_path(_IDF_VERSION) + env_python = get_python_env_executable_path(python_env_path, "python") + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_not_called() + + +def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing idf_tools install raises.""" + espidf_mocks.run_ok.side_effect = [False] + with pytest.raises(RuntimeError, match="framework installation failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_pip_upgrade_failure( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing pip upgrade in the python env raises (framework install ok).""" + espidf_mocks.run_ok.side_effect = [True, False] + with pytest.raises(RuntimeError, match="Python environment packages failure"): + check_esp_idf_install(_IDF_VERSION, force=True) + + +def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) -> None: + """A failing feature requirements install raises.""" + espidf_mocks.run_ok.side_effect = [True, True, False] + with pytest.raises(RuntimeError, match="Python dependencies for"): + check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"]) + + +def _mark_installed() -> None: + """Create the extracted marker and python-env interpreter so the install + check takes the already-installed path rather than force-installing.""" + (_get_framework_path(_IDF_VERSION) / ".esphome_extracted").touch() + env_python = get_python_env_executable_path( + _get_python_env_path(_IDF_VERSION), "python" + ) + env_python.parent.mkdir(parents=True, exist_ok=True) + env_python.touch() + + +def test_check_esp_idf_install_stamp_mismatch_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A stamp mismatch reinstalls tools (marker present, so no re-extract).""" + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", return_value=False): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # marker present -> no re-extract + espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt + + +def test_check_esp_idf_install_check_command_failure_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + _mark_installed() + # idf_tools check fails -> install stays True; the later installs succeed. + espidf_mocks.run_ok.side_effect = [False, True, True, True] + check_esp_idf_install(_IDF_VERSION, features=["fb"]) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unknown_python_version_reinstalls( + espidf_mocks: SimpleNamespace, +) -> None: + """An undeterminable python version rebuilds the venv (framework stamp still ok).""" + _mark_installed() + with patch("esphome.espidf.framework._get_python_version", return_value=None): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() # framework stamp matched + espidf_mocks.venv.assert_called_once() # python env rebuilt + + +def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( + espidf_mocks: SimpleNamespace, +) -> None: + """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" + + # _check_stamp passes for the framework (no python_version key) and fails + # for the python env (carries python_version), so only the venv rebuilds. + def stamp_ok(_stamp_file, info: dict) -> bool: + return "python_version" not in info + + _mark_installed() + with patch("esphome.espidf.framework._check_stamp", side_effect=stamp_ok): + check_esp_idf_install(_IDF_VERSION) + + espidf_mocks.extract.assert_not_called() + espidf_mocks.venv.assert_called_once() + + +def test_check_esp_idf_install_unparseable_version( + espidf_mocks: SimpleNamespace, +) -> None: + """A non-semver version skips the MAJOR/MINOR substitutions without erroring.""" + bad_version = "main" + _get_framework_path(bad_version).mkdir(parents=True, exist_ok=True) + check_esp_idf_install(bad_version, force=True) + + espidf_mocks.extract.assert_called_once() + + +# --------------------------------------------------------------------------- +# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport) +# --------------------------------------------------------------------------- + + +def _write_tools_json(framework_path: Path, data: dict) -> Path: + tools_dir = framework_path / "tools" + tools_dir.mkdir(parents=True, exist_ok=True) + tools_json = tools_dir / "tools.json" + tools_json.write_text(json.dumps(data), encoding="utf-8") + return tools_json + + +def test_patch_tools_json_non_aarch64_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, {"tools": [{"name": "ninja", "versions": [{"name": "1.12.1"}]}]} + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="x86_64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +def test_patch_tools_json_missing_file_is_noop(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # no tools/tools.json present + + +def test_patch_tools_json_corrupt_file_warns_and_skips(tmp_path: Path) -> None: + (tmp_path / "tools").mkdir() + (tmp_path / "tools" / "tools.json").write_text("{ not json", encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) # JSONDecodeError -> skip + + +def test_patch_tools_json_injects_ninja_arm64(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + {"name": "ninja", "versions": [{"name": "1.12.1"}]}, + {"name": "cmake", "versions": [{"name": "3.24.0"}]}, + ] + }, + ) + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + ninja = next(t for t in data["tools"] if t["name"] == "ninja") + assert "linux-arm64" in ninja["versions"][0] + assert ninja["versions"][0]["linux-arm64"]["size"] == 121787 + + +def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None: + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "ninja", + "versions": [{"name": "1.12.1", "linux-arm64": {"url": "x"}}], + } + ] + }, + ) + before = tools_json.read_text(encoding="utf-8") + with patch("esphome.espidf.framework.platform.machine", return_value="aarch64"): + _patch_tools_json_for_linux_arm64(tmp_path) + assert tools_json.read_text(encoding="utf-8") == before + + +# --------------------------------------------------------------------------- +# Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env +# --------------------------------------------------------------------------- + + +def test_get_idf_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "5.1.2\n", "") + ): + assert _get_idf_version(tmp_path) == "5.1.2" + + +def test_get_idf_version_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "boom")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF version"), + ): + _get_idf_version(tmp_path) + + +def test_get_idf_tool_paths_parses_json(tmp_path: Path) -> None: + payload = json.dumps({"paths_to_export": ["/a", "/b"], "export_vars": {"X": "1"}}) + with patch( + "esphome.espidf.framework.run_command", return_value=(True, payload, "") + ): + paths, export_vars = _get_idf_tool_paths(tmp_path) + assert paths == ["/a", "/b"] + assert export_vars == {"X": "1"} + + +def test_get_idf_tool_paths_raises_on_bad_json(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework.run_command", return_value=(True, "not json", "") + ), + pytest.raises(RuntimeError, match="Can't extract ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_idf_tool_paths_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "err")), + pytest.raises(RuntimeError, match="Can't get ESP-IDF tool paths"), + ): + _get_idf_tool_paths(tmp_path) + + +def test_get_python_version_parses_stdout(tmp_path: Path) -> None: + with patch( + "esphome.espidf.framework.run_command", return_value=(True, "3.11.0\n", "") + ): + assert _get_python_version(tmp_path / "python") == "3.11.0" + + +def test_get_python_version_returns_falsy_on_failure(tmp_path: Path) -> None: + with patch("esphome.espidf.framework.run_command", return_value=(False, "", "")): + # non-throwing failure returns the (empty) stdout as-is + assert not _get_python_version(tmp_path / "python") + + +def test_get_python_version_raises_when_requested(tmp_path: Path) -> None: + with ( + patch("esphome.espidf.framework.run_command", return_value=(False, "", "")), + pytest.raises(RuntimeError, match="Can't get Python version"), + ): + _get_python_version(tmp_path / "python", throw_exception=True) + + +def test_write_stamp_writes_json(tmp_path: Path) -> None: + stamp = tmp_path / "stamp.json" + _write_stamp(stamp, {"a": "1", "b": "2"}) + assert json.loads(stamp.read_text(encoding="utf-8")) == {"a": "1", "b": "2"} + + +def test_get_framework_env_with_python_env(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch( + "esphome.espidf.framework._get_idf_tool_paths", + return_value=(["/tool/bin"], {"IDF_X": "1"}), + ), + ): + env = get_framework_env( + tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} + ) + + assert env["IDF_PATH"] == str(tmp_path / "fw") + assert env["ESP_IDF_VERSION"] == "5.1.2" + assert env["IDF_X"] == "1" + assert env["IDF_PYTHON_ENV_PATH"] == str(tmp_path / "penv") + assert "/tool/bin" in env["PATH"] + + +def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), + patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + ): + env = get_framework_env(tmp_path / "fw") + + assert "IDF_PYTHON_ENV_PATH" not in env + assert env["PATH"] # taken from os.environ + + +# --------------------------------------------------------------------------- +# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# --------------------------------------------------------------------------- + + +def test_check_stamp_matches(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is True + + +def test_check_stamp_mismatch(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + assert _check_stamp(f, {"a": "2"}) is False + + +def test_check_stamp_missing_file(tmp_path: Path) -> None: + assert _check_stamp(tmp_path / "nope.json", {"a": "1"}) is False + + +def test_check_stamp_corrupt_file(tmp_path: Path) -> None: + f = tmp_path / "s.json" + f.write_text("{ not json", encoding="utf-8") + assert _check_stamp(f, {"a": "1"}) is False + + +def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" + + +def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: + (tmp_path / "version.txt").write_text("existing\n", encoding="utf-8") + _write_idf_version_txt(tmp_path, "5.1.2") + assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" + + +def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: + override = str(tmp_path / "custom-idf") + with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): + assert _get_idf_tools_path() == Path(override) + + +def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: + with patch("pathlib.Path.write_text", side_effect=OSError("denied")): + # write failure is caught and warned, not raised + _write_idf_version_txt(tmp_path, "5.1.2") diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py new file mode 100644 index 0000000000..a8533608c0 --- /dev/null +++ b/tests/unit_tests/test_framework_helpers.py @@ -0,0 +1,954 @@ +"""Tests for esphome.framework_helpers.""" + +# pylint: disable=protected-access + +import importlib.util +import io +import logging +import os +from pathlib import Path +import subprocess +import sys +import tarfile +from unittest.mock import MagicMock, Mock, patch +import zipfile + +import pytest +import requests as req + +from esphome.framework_helpers import ( + _7z_extract_all, + _detect_archive_root, + _rename_with_retry, + _tar_extract_all, + _zip_extract_all, + archive_extract_all, + create_venv, + download_from_mirrors, + get_python_env_executable_path, + get_system_python_path, + rmdir, + run_command, + run_command_ok, + str_to_lst_of_str, +) + +_HAS_PY7ZR = importlib.util.find_spec("py7zr") is not None + +# --------------------------------------------------------------------------- +# str_to_lst_of_str +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("a;b;c", ["a", "b", "c"]), + (" a ; b ", ["a", "b"]), + (";; a ;;", ["a"]), + ("single", ["single"]), + ("", []), + (["already", "a", "list"], ["already", "a", "list"]), + ], +) +def test_str_to_lst_of_str(value: str | list, expected: list) -> None: + assert str_to_lst_of_str(value) == expected + + +# --------------------------------------------------------------------------- +# rmdir +# --------------------------------------------------------------------------- + + +def test_rmdir_nonexistent_is_noop(tmp_path: Path) -> None: + rmdir(tmp_path / "missing") + + +def test_rmdir_removes_existing_directory(tmp_path: Path) -> None: + d = tmp_path / "to_remove" + d.mkdir() + (d / "file.txt").write_text("x") + rmdir(d) + assert not d.exists() + + +def test_rmdir_logs_debug_with_msg( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + d = tmp_path / "logged" + d.mkdir() + with caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"): + rmdir(d, msg="cleanup message") + assert "cleanup message" in caplog.text + + +def test_rmdir_raises_runtime_error_on_os_error(tmp_path: Path) -> None: + d = tmp_path / "stubborn" + d.mkdir() + with ( + patch("esphome.framework_helpers.rmtree", side_effect=OSError("perm denied")), + pytest.raises(RuntimeError, match="can't remove"), + ): + rmdir(d, msg="cleanup step") + + +# --------------------------------------------------------------------------- +# get_system_python_path +# --------------------------------------------------------------------------- + + +def test_get_system_python_path_returns_env_var() -> None: + with patch.dict(os.environ, {"PYTHONEXEPATH": "/custom/python"}): + assert get_system_python_path() == "/custom/python" + + +def test_get_system_python_path_falls_back_to_sys_executable() -> None: + env = {k: v for k, v in os.environ.items() if k != "PYTHONEXEPATH"} + with patch.dict(os.environ, env, clear=True): + assert get_system_python_path() == os.path.normpath(sys.executable) + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(os.name != "posix", reason="PosixPath construction requires POSIX") +def test_get_python_env_executable_path_posix() -> None: + assert get_python_env_executable_path("/env", "python") == Path("/env/bin/python") + + +@pytest.mark.skipif(os.name != "nt", reason="WindowsPath construction requires Windows") +def test_get_python_env_executable_path_windows() -> None: + assert get_python_env_executable_path("/env", "python") == Path( + "/env/Scripts/python.exe" + ) + + +# --------------------------------------------------------------------------- +# run_command +# --------------------------------------------------------------------------- + + +def test_run_command_success_returns_stdout(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="out\n", stderr="") + ok, stdout, _stderr = run_command(["echo", "hello"]) + assert ok is True + assert stdout == "out\n" + + +def test_run_command_failure_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="boom") + ok, _stdout, stderr = run_command(["bad"]) + assert ok is False + assert stderr == "boom" + + +def test_run_command_stream_output_success(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0) + ok, stdout, stderr = run_command(["cmd"], stream_output=True) + assert ok is True + assert stdout is None + assert stderr is None + + +def test_run_command_stream_output_failure(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=2) + ok, stdout, _stderr = run_command(["cmd"], stream_output=True) + assert ok is False + assert stdout is None + + +def test_run_command_subprocess_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = subprocess.SubprocessError("exploded") + ok, stdout, stderr = run_command(["cmd"]) + assert ok is False + assert stdout is None + assert stderr is None + + +def test_run_command_os_error_returns_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.side_effect = OSError("not found") + ok, _stdout, _stderr = run_command(["cmd"]) + assert ok is False + + +def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + run_command(["cmd"], env={"MY_VAR": "42"}) + assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" + + +def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + run_command(["cmd"], cwd=str(tmp_path)) + assert mock_subprocess_run.call_args[1]["cwd"] == str(tmp_path) + + +# --------------------------------------------------------------------------- +# run_command_ok +# --------------------------------------------------------------------------- + + +def test_run_command_ok_true(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + assert run_command_ok(["cmd"]) is True + + +def test_run_command_ok_false(mock_subprocess_run: Mock) -> None: + mock_subprocess_run.return_value = Mock(returncode=1, stdout="", stderr="") + assert run_command_ok(["cmd"]) is False + + +# --------------------------------------------------------------------------- +# create_venv +# --------------------------------------------------------------------------- + + +def test_create_venv_calls_run_command_ok(tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.run_command_ok", return_value=True + ) as mock_cmd: + create_venv(tmp_path / "env", msg="test") + mock_cmd.assert_called_once() + + +def test_create_venv_raises_on_failure(tmp_path: Path) -> None: + with ( + patch("esphome.framework_helpers.run_command_ok", return_value=False), + pytest.raises(RuntimeError, match="Can't create Python virtual environment"), + ): + create_venv(tmp_path / "env", msg="test") + + +# --------------------------------------------------------------------------- +# _detect_archive_root +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("names", "expected"), + [ + (["wrapper/", "wrapper/a.txt", "wrapper/sub/b.txt"], "wrapper"), + (["root1/a.txt", "root2/b.txt"], None), + (["wrapper"], None), # no descendant → None + (["", "wrapper/file.txt"], "wrapper"), # empty names skipped + (["wrapper\\file.txt"], "wrapper"), # backslash normalised + (["w/a", "w/b", "w/c"], "w"), + ], +) +def test_detect_archive_root(names: list[str], expected: str | None) -> None: + assert _detect_archive_root(names) == expected + + +# --------------------------------------------------------------------------- +# Tar archive helpers +# --------------------------------------------------------------------------- + + +def _make_tar( + members: list[tarfile.TarInfo], + file_contents: dict[str, bytes] | None = None, +) -> io.BytesIO: + buf = io.BytesIO() + contents = file_contents or {} + with tarfile.open(fileobj=buf, mode="w") as tf: + for info in members: + if info.isreg() and info.name in contents: + data = contents[info.name] + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + else: + tf.addfile(info) + buf.seek(0) + return buf + + +def _reg(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + return info + + +def _dir(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.DIRTYPE + info.mode = 0o755 + return info + + +def _sym(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.SYMTYPE + info.linkname = target + info.mode = 0o777 + return info + + +def _special(name: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.CHRTYPE + info.mode = 0o600 + return info + + +def _hlnk(name: str, target: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(name=name) + info.type = tarfile.LNKTYPE + info.linkname = target + info.mode = 0o644 + return info + + +# --------------------------------------------------------------------------- +# _tar_extract_all — branches not covered by the hard-link prefix-strip tests +# --------------------------------------------------------------------------- + + +class TestTarExtractAllSecurity: + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + """Without a single common root files land directly in extract_dir.""" + buf = _make_tar( + [_reg("a.txt"), _reg("b.txt")], + {"a.txt": b"aaa", "b.txt": b"bbb"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_bytes() == b"aaa" + assert (tmp_path / "b.txt").read_bytes() == b"bbb" + + def test_directory_member_extracted(self, tmp_path: Path) -> None: + buf = _make_tar([_dir("subdir/")]) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "subdir").is_dir() + + def test_symlink_within_dest_extracted(self, tmp_path: Path) -> None: + buf = _make_tar( + [_reg("target.txt"), _sym("link.txt", "target.txt")], + {"target.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "link.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Member resolving outside extract_dir via .. is silently skipped.""" + info = tarfile.TarInfo(name="sub/../../escape.txt") + info.type = tarfile.REGTYPE + info.size = 5 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info, io.BytesIO(b"OOPS!")) + buf.seek(0) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path.parent / "escape.txt").exists() + assert not list(tmp_path.rglob("escape.txt")) + + def test_absolute_symlink_target_skipped(self, tmp_path: Path) -> None: + """Symlink pointing to an absolute path is silently skipped.""" + buf = _make_tar( + [_reg("real.txt"), _sym("danger.lnk", "/etc/passwd")], + {"real.txt": b"ok"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "danger.lnk").exists() + + def test_symlink_escaping_dest_skipped(self, tmp_path: Path) -> None: + """Symlink whose resolved path exits extract_dir is silently skipped.""" + buf = _make_tar([_sym("up.lnk", "../outside.txt")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "up.lnk").exists() + + def test_special_file_skipped(self, tmp_path: Path) -> None: + """Character-device and other special-file members are silently skipped.""" + buf = _make_tar([_special("chardev")]) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "chardev").exists() + + @pytest.mark.skipif( + os.name == "nt", reason="Windows has no POSIX executable permission bit" + ) + def test_executable_bit_preserved(self, tmp_path: Path) -> None: + """User-executable bit is kept for explicitly executable files.""" + info = _reg("script.sh") + info.mode = 0o755 + buf = _make_tar([info], {"script.sh": b"#!/bin/sh"}) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "script.sh").stat().st_mode & 0o100 # S_IXUSR + + def test_non_executable_exec_bits_stripped(self, tmp_path: Path) -> None: + """Exec bits are removed when S_IXUSR is not set.""" + info = _reg("data.bin") + info.mode = 0o654 # group/other exec present, user exec absent + buf = _make_tar([info], {"data.bin": b"\x00"}) + _tar_extract_all(buf, tmp_path) + mode = (tmp_path / "data.bin").stat().st_mode + assert not (mode & 0o111) # all exec bits cleared + + +# --------------------------------------------------------------------------- +# ZIP archive helper +# --------------------------------------------------------------------------- + + +def _make_zip(entries: list[tuple[str, str | bytes]]) -> io.BytesIO: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in entries: + zf.writestr(name, content) + buf.seek(0) + return buf + + +# --------------------------------------------------------------------------- +# _zip_extract_all +# --------------------------------------------------------------------------- + + +class TestZipExtractAll: + def test_basic_extraction_strips_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("wrapper/file.txt", "hello")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "hello" + + def test_flat_archive_no_wrapper(self, tmp_path: Path) -> None: + buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "a.txt").read_text() == "aaa" + assert (tmp_path / "b.txt").read_text() == "bbb" + + def test_wrapper_root_entry_skipped(self, tmp_path: Path) -> None: + """The wrapper directory entry itself (step 3a) does not appear in dest.""" + buf = _make_zip([("wrapper/", ""), ("wrapper/file.txt", "content")]) + _zip_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_text() == "content" + assert not (tmp_path / "wrapper").exists() + + def test_path_traversal_raises(self, tmp_path: Path) -> None: + # Two members with different roots so _detect_archive_root returns None + # and strip_prefix is not applied, leaving "../escape.txt" to hit the + # commonpath safety check directly. + buf = _make_zip([("safe.txt", "ok"), ("../escape.txt", "bad")]) + with pytest.raises(ValueError, match="Unsafe path"): + _zip_extract_all(buf, tmp_path) + + def test_multiple_files_extracted(self, tmp_path: Path) -> None: + entries = [(f"root/{c}.txt", c * 3) for c in "abc"] + buf = _make_zip(entries) + _zip_extract_all(buf, tmp_path) + for c in "abc": + assert (tmp_path / f"{c}.txt").read_text() == c * 3 + + +# --------------------------------------------------------------------------- +# archive_extract_all dispatch +# --------------------------------------------------------------------------- + + +def _gzip_tar_bytes(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for name, content in entries.items(): + info = tarfile.TarInfo(name=name) + info.size = len(content) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +class TestArchiveExtractAll: + def test_path_input_gzip_tar(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_bytes() == b"hello" + + def test_buffered_reader_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"data"})) + dest = tmp_path / "out" + dest.mkdir() + with archive.open("rb") as f: # io.BufferedReader + archive_extract_all(f, dest) + assert (dest / "file.txt").read_bytes() == b"data" + + def test_rawio_input(self, tmp_path: Path) -> None: + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"raw"})) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(io.FileIO(archive), dest) + assert (dest / "file.txt").read_bytes() == b"raw" + + def test_zip_dispatched(self, tmp_path: Path) -> None: + archive = tmp_path / "test.zip" + archive.write_bytes(_make_zip([("file.txt", "hi")]).getvalue()) + dest = tmp_path / "out" + dest.mkdir() + archive_extract_all(archive, dest) + assert (dest / "file.txt").read_text() == "hi" + + def test_invalid_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="archive must be"): + archive_extract_all(42, ".") # type: ignore[arg-type] + + def test_unsupported_format_raises_value_error(self, tmp_path: Path) -> None: + bad = tmp_path / "bad.bin" + bad.write_bytes(b"\x00\x01\x02\x03\x04\x05\x06") + with pytest.raises(ValueError, match="Unsupported archive format"): + archive_extract_all(bad, tmp_path) + + +# --------------------------------------------------------------------------- +# download_from_mirrors +# --------------------------------------------------------------------------- + + +def _mock_response(content: bytes, ok: bool = True) -> MagicMock: + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + if ok: + r.raise_for_status.return_value = None + else: + r.raise_for_status.side_effect = req.HTTPError("503") + r.headers = {"content-length": "0"} # suppress ProgressBar + r.iter_content.return_value = [content] if content else [] + return r + + +class TestDownloadFromMirrors: + def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: + target = tmp_path / "out.bin" + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"filedata"), + ): + url = download_from_mirrors(["https://example.com/f"], {}, target) + assert url == "https://example.com/f" + assert target.read_bytes() == b"filedata" + + def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"x"), + ) as mock_get: + download_from_mirrors( + ["https://example.com/{VERSION}.bin"], + {"VERSION": "1.2.3"}, + tmp_path / "out.bin", + ) + assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin" + + def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: + with patch( + "esphome.framework_helpers.requests.get", + side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], + {}, + tmp_path / "out.bin", + ) + assert url == "https://mirror2.com/f" + assert (tmp_path / "out.bin").read_bytes() == b"second" + + def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: + with ( + patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"", ok=False), + ), + pytest.raises(req.HTTPError), + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + + def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="empty mirrors list"): + download_from_mirrors([], {}, tmp_path / "out.bin") + + def test_invalid_target_type_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="target must be"): + download_from_mirrors(["https://example.com/f"], {}, 42) # type: ignore[arg-type] + + def test_file_like_target_written(self) -> None: + buf = io.BytesIO() + with patch( + "esphome.framework_helpers.requests.get", + return_value=_mock_response(b"bytes"), + ): + download_from_mirrors(["https://example.com/f"], {}, buf) + buf.seek(0) + assert buf.read() == b"bytes" + + def test_progress_bar_shown_when_content_length_known(self, tmp_path: Path) -> None: + r = _mock_response(b"1234567890") + r.headers = {"content-length": "10"} + with ( + patch("esphome.framework_helpers.requests.get", return_value=r), + patch("esphome.framework_helpers.ProgressBar") as mock_pb, + ): + download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") + mock_pb.assert_called_once_with("Downloading") + mock_pb.return_value.update.assert_called() + + def test_empty_chunk_not_written(self, tmp_path: Path) -> None: + """Empty chunks yielded by iter_content are skipped without writing.""" + r = MagicMock() + r.__enter__.return_value = r + r.__exit__.return_value = False + r.raise_for_status.return_value = None + r.headers = {"content-length": "0"} + r.iter_content.return_value = [b""] # one empty chunk + target = tmp_path / "out.bin" + with patch("esphome.framework_helpers.requests.get", return_value=r): + download_from_mirrors(["https://example.com/f"], {}, target) + assert target.exists() + assert target.read_bytes() == b"" + + +# --------------------------------------------------------------------------- +# get_python_env_executable_path — Windows branch +# --------------------------------------------------------------------------- + + +def test_get_python_env_executable_path_nt() -> None: + """Windows path uses Scripts/ and .exe suffix.""" + from pathlib import PurePosixPath + + with ( + patch.object(os, "name", "nt"), + patch("esphome.framework_helpers.Path", PurePosixPath), + ): + result = get_python_env_executable_path("/env", "python") + assert str(result) == "/env/Scripts/python.exe" + + +# --------------------------------------------------------------------------- +# _tar_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestTarExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + info = tarfile.TarInfo(name="C:/secret.txt") + info.type = tarfile.REGTYPE + info.size = 0 + info.mode = 0o644 + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + tf.addfile(info) + buf.seek(0) + with patch.object(os, "name", "nt"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_strip_root_exact_match_skipped(self, tmp_path: Path) -> None: + """Member whose name equals strip_root exactly (no trailing slash) is skipped.""" + # "wrapper" (file entry) + "wrapper/file.txt" causes _detect_archive_root + # to return "wrapper"; the bare "wrapper" entry matches strip_root exactly. + buf = _make_tar( + [_reg("wrapper"), _reg("wrapper/file.txt")], + {"wrapper/file.txt": b"content"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "wrapper").exists() + assert (tmp_path / "file.txt").read_bytes() == b"content" + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_tar([_reg("other/file.txt")], {"other/file.txt": b"data"}) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _tar_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_hardlink_prefix_stripped(self, tmp_path: Path) -> None: + """Hard-link linkname has wrapper prefix stripped along with its entry name.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert (tmp_path / "file.txt").read_bytes() == b"data" + assert (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_equals_strip_root_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname equals strip_root is silently skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "wrapper")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_hardlink_linkname_outside_prefix_skipped(self, tmp_path: Path) -> None: + """Hard link whose linkname doesn't start with strip_prefix is skipped.""" + buf = _make_tar( + [_reg("wrapper/file.txt"), _hlnk("wrapper/link.txt", "other/file.txt")], + {"wrapper/file.txt": b"data"}, + ) + _tar_extract_all(buf, tmp_path) + assert not (tmp_path / "link.txt").exists() + + def test_member_mode_none_skips_sanitization(self, tmp_path: Path) -> None: + """Member with mode=None bypasses the sanitization block without error.""" + info = _reg("file.txt") + buf = _make_tar([info], {"file.txt": b"data"}) + buf.seek(0) + with tarfile.open(fileobj=buf) as tf: + members = tf.getmembers() + for m in members: + m.mode = None + buf.seek(0) + with ( + patch("tarfile.TarFile.getmembers", return_value=members), + patch("tarfile.TarFile.extract"), + ): + _tar_extract_all(buf, tmp_path) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_tar([_reg("file.txt")], {"file.txt": b"x"}) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _tar_extract_all(buf, tmp_path, progress_header="Extracting") + mock_pb.assert_called_once_with("Extracting") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _zip_extract_all — additional branch coverage +# --------------------------------------------------------------------------- + + +class TestZipExtractAllBranches: + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="patching os.name makes pathlib build a WindowsPath, which only " + "instantiates on POSIX in 3.12+", + ) + def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: + """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" + buf = _make_zip([("C:/secret.txt", "bad")]) + with patch.object(os, "name", "nt"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_member_not_under_strip_prefix_skipped(self, tmp_path: Path) -> None: + """Member whose name doesn't start with strip_prefix is silently skipped.""" + buf = _make_zip([("other/file.txt", "data")]) + with patch("esphome.framework_helpers._detect_archive_root", return_value="w"): + _zip_extract_all(buf, tmp_path) + assert not list(tmp_path.rglob("*")) + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + """A non-empty progress_header causes ProgressBar to be created and updated.""" + buf = _make_zip([("file.txt", "hello")]) + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _zip_extract_all(buf, tmp_path, progress_header="Unzipping") + mock_pb.assert_called_once_with("Unzipping") + mock_pb.return_value.update.assert_called() + + +# --------------------------------------------------------------------------- +# _rename_with_retry +# --------------------------------------------------------------------------- + + +class TestRenameWithRetry: + def test_success_on_first_attempt(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst) + assert dst.read_text() == "data" + assert not src.exists() + + def test_retries_on_permission_error_then_succeeds(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + call_count = 0 + original_rename = Path.rename + + def flaky_rename(self, target): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise PermissionError("locked") + return original_rename(self, target) + + with ( + patch.object(Path, "rename", flaky_rename), + patch("esphome.framework_helpers.time.sleep"), + ): + _rename_with_retry(src, dst, attempts=3) + assert dst.read_text() == "data" + + def test_raises_after_all_attempts_fail(self, tmp_path: Path) -> None: + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + with ( + patch.object(Path, "rename", side_effect=PermissionError("locked")), + patch("esphome.framework_helpers.time.sleep"), + pytest.raises(PermissionError), + ): + _rename_with_retry(src, dst, attempts=3) + + def test_attempts_zero_is_noop(self, tmp_path: Path) -> None: + """Zero attempts means the for-loop body never runs; src is untouched.""" + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + _rename_with_retry(src, dst, attempts=0) + assert src.exists() + assert not dst.exists() + + +# --------------------------------------------------------------------------- +# _7z_extract_all +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _HAS_PY7ZR, reason="py7zr not installed") +class TestSevenZipExtractAll: + @staticmethod + def _make_7z(entries: dict[str, bytes]) -> io.BytesIO: + import py7zr + + buf = io.BytesIO() + with py7zr.SevenZipFile(buf, "w") as sz: + for name, content in entries.items(): + sz.writef(io.BytesIO(content), name) + buf.seek(0) + return buf + + def test_basic_extraction_no_wrapper(self, tmp_path: Path) -> None: + buf = self._make_7z({"a.txt": b"aaa", "b.txt": b"bbb"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "a.txt").exists() + assert (out / "b.txt").exists() + + def test_strips_wrapper_directory(self, tmp_path: Path) -> None: + buf = self._make_7z({"wrapper/file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + assert not (out / "wrapper").exists() + + def test_staging_suffix_collision(self, tmp_path: Path) -> None: + """When .extract_tmp_0 already exists, suffix is incremented to find a free slot.""" + out = tmp_path / "out" + out.mkdir() + (out / ".extract_tmp_0").mkdir() + buf = self._make_7z({"file.txt": b"hi"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + # .extract_tmp_1 should be cleaned up after extraction + assert not (out / ".extract_tmp_1").exists() + + def test_overwrites_existing_directory(self, tmp_path: Path) -> None: + """Pre-existing destination directory is replaced.""" + out = tmp_path / "out" + out.mkdir() + existing_dir = out / "file.txt" + existing_dir.mkdir() + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + """Pre-existing destination file is replaced.""" + out = tmp_path / "out" + out.mkdir() + (out / "file.txt").write_bytes(b"old") + buf = self._make_7z({"file.txt": b"new"}) + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_empty_name_skipped(self, tmp_path: Path) -> None: + """Archive entries with empty names are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"data"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["", "file.txt"] + ): + _7z_extract_all(buf, out) + assert (out / "file.txt").exists() + + def test_path_traversal_skipped(self, tmp_path: Path) -> None: + """Entries whose resolved path exits extract_dir are skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + with patch.object( + py7zr.SevenZipFile, "getnames", return_value=["../escape.txt", "file.txt"] + ): + _7z_extract_all(buf, out) + assert not (tmp_path / "escape.txt").exists() + assert (out / "file.txt").exists() + + def test_progress_bar_shown(self, tmp_path: Path) -> None: + buf = self._make_7z({"file.txt": b"x"}) + out = tmp_path / "out" + out.mkdir() + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _7z_extract_all(buf, out, progress_header="Unpacking 7z") + mock_pb.assert_called_once_with("Unpacking 7z") + mock_pb.return_value.update.assert_called() + + def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None: + """Names that resolve as absolute are silently skipped.""" + import py7zr + + buf = self._make_7z({"file.txt": b"safe"}) + out = tmp_path / "out" + out.mkdir() + + original_is_absolute = Path.is_absolute + + def patched_is_absolute(self: Path) -> bool: + if str(self).startswith("C:"): + return True + return original_is_absolute(self) + + with ( + patch.object( + py7zr.SevenZipFile, "getnames", return_value=["C:/evil.txt", "file.txt"] + ), + patch.object(Path, "is_absolute", patched_is_absolute), + ): + _7z_extract_all(buf, out) + # Avoid `out / "C:"` here: pathlib treats "C:" as a drive (always + # "exists" on Windows). Assert on the actual extracted files instead. + extracted = sorted(p.name for p in out.rglob("*") if p.is_file()) + assert extracted == ["file.txt"] + + def test_dispatched_via_archive_extract_all(self, tmp_path: Path) -> None: + """archive_extract_all dispatches 7z archives to _7z_extract_all.""" + buf = self._make_7z({"hello.txt": b"world"}) + data = buf.read() + assert data[:6] == b"\x37\x7a\xbc\xaf\x27\x1c" + archive = tmp_path / "test.7z" + archive.write_bytes(data) + out = tmp_path / "out" + out.mkdir() + archive_extract_all(archive, out) + assert (out / "hello.txt").exists() diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py new file mode 100644 index 0000000000..9652ad08eb --- /dev/null +++ b/tests/unit_tests/test_nrf52_framework.py @@ -0,0 +1,219 @@ +"""Tests for esphome.components.nrf52.framework helpers.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52.framework import ( + _TOOLCHAIN_VERSION, + _get_toolchain_platform_info, + check_and_install, +) +from esphome.config_validation import Version +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + # default — no branch hit + ("Linux", "x86_64", ("linux", "x86_64", "tar.xz")), + # arm64 → aarch64 rename + ("Linux", "arm64", ("linux", "aarch64", "tar.xz")), + # darwin → macos rename only + ("Darwin", "x86_64", ("macos", "x86_64", "tar.xz")), + # both renames apply + ("Darwin", "arm64", ("macos", "aarch64", "tar.xz")), + # windows forces x86_64 + 7z; arm64 rename is overwritten + ("Windows", "arm64", ("windows", "x86_64", "7z")), + ], +) +def test_get_toolchain_platform_info( + system: str, machine: str, expected: tuple[str, str, str] +) -> None: + with ( + patch("platform.system", return_value=system), + patch("platform.machine", return_value=machine), + ): + assert _get_toolchain_platform_info() == expected + + +# --------------------------------------------------------------------------- +# Helpers and fixtures for check_and_install tests +# --------------------------------------------------------------------------- + +_TEST_SDK_VERSION = "2.9.0" + + +@pytest.fixture +def nrf52_dirs(setup_core: Path) -> SimpleNamespace: + """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} + tools = CORE.data_dir / "sdk-nrf" + python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" + framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" + toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + for d in (python_env, framework, toolchain_dir): + d.mkdir(parents=True, exist_ok=True) + return SimpleNamespace( + python_env=python_env, + framework=framework, + toolchain=toolchain_dir, + ) + + +@pytest.fixture +def mock_nrf52_ops(): + """Patch all heavy I/O operations used by check_and_install.""" + with ( + patch("esphome.components.nrf52.framework.rmdir") as mock_rmdir, + patch("esphome.components.nrf52.framework.create_venv") as mock_create_venv, + patch( + "esphome.components.nrf52.framework.run_command_ok", return_value=True + ) as mock_run_cmd, + patch( + "esphome.components.nrf52.framework.download_from_mirrors", + return_value="https://example.com/tc.tar.xz", + ) as mock_download, + patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract, + ): + yield SimpleNamespace( + rmdir=mock_rmdir, + create_venv=mock_create_venv, + run_command_ok=mock_run_cmd, + download_from_mirrors=mock_download, + archive_extract_all=mock_extract, + ) + + +# --------------------------------------------------------------------------- +# check_and_install tests +# --------------------------------------------------------------------------- + + +class TestCheckAndInstall: + def test_all_installed_skips_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """All three sentinels present → nothing downloaded or compiled.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.toolchain / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_not_called() + mock_nrf52_ops.archive_extract_all.assert_not_called() + + def test_fresh_install_runs_all_steps( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """No sentinels → venv created, west installed, SDK init+update, toolchain downloaded.""" + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + # pip install west, west init, west update + assert mock_nrf52_ops.run_command_ok.call_count == 3 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.framework / ".ready").exists() + assert (nrf52_dirs.toolchain / ".ready").exists() + + def test_venv_exists_installs_framework_and_toolchain( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv ready but framework missing → skip venv creation, run SDK init+update.""" + (nrf52_dirs.python_env / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + # west init + west update only (no pip install) + assert mock_nrf52_ops.run_command_ok.call_count == 2 + mock_nrf52_ops.download_from_mirrors.assert_called_once() + + def test_toolchain_only_missing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Venv and framework ready → only toolchain downloaded and extracted.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + check_and_install() + + mock_nrf52_ops.create_venv.assert_not_called() + mock_nrf52_ops.run_command_ok.assert_not_called() + mock_nrf52_ops.download_from_mirrors.assert_called_once() + mock_nrf52_ops.archive_extract_all.assert_called_once() + + def test_west_install_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing pip install west raises EsphomeError.""" + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Install west"): + check_and_install() + + def test_framework_init_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west init raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Can't initialize"): + check_and_install() + + def test_framework_update_failure_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Failing west update raises EsphomeError.""" + (nrf52_dirs.python_env / ".ready").touch() + # init succeeds, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + def test_toolchain_download_passes_platform_substitutions( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" + (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.framework / ".ready").touch() + + with patch( + "esphome.components.nrf52.framework._get_toolchain_platform_info", + return_value=("linux", "x86_64", "tar.xz"), + ): + check_and_install() + + args, _ = mock_nrf52_ops.download_from_mirrors.call_args + substitutions = args[1] + assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["sysname"] == "linux" + assert substitutions["machine"] == "x86_64" + assert substitutions["extension"] == "tar.xz" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index c1d16530cb..a37b19f584 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -442,6 +442,21 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") +def test_run_compile_without_process_limit( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """When no compile_process_limit is set, run_compile passes no -j flag.""" + from esphome.const import CONF_ESPHOME + + CORE.build_path = str(setup_core / "build" / "test") + config = {CONF_ESPHOME: {}} + mock_run_platformio_cli_run.return_value = 0 + + toolchain.run_compile(config, verbose=False) + + mock_run_platformio_cli_run.assert_called_once_with(config, False) + + def test_get_idedata_caches_result( setup_core: Path, mock_run_platformio_cli_run: Mock ) -> None: From 8206df6e4e21ffb855be60865a832c142f473f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Lohynsk=C3=BD?= <85194189+Tomer27cz@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:57:13 +0200 Subject: [PATCH 267/282] [dlms_meter] dlms_parser library (#15458) Co-authored-by: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@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: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- CODEOWNERS | 2 +- esphome/components/dlms_meter/__init__.py | 253 ++++++- .../dlms_meter/binary_sensor/__init__.py | 20 + esphome/components/dlms_meter/dlms.h | 71 -- esphome/components/dlms_meter/dlms_meter.cpp | 674 +++++------------- esphome/components/dlms_meter/dlms_meter.h | 181 +++-- esphome/components/dlms_meter/mbus.h | 69 -- esphome/components/dlms_meter/obis.h | 94 --- .../components/dlms_meter/sensor/__init__.py | 228 +++--- .../dlms_meter/text_sensor/__init__.py | 68 +- esphome/idf_component.yml | 2 + platformio.ini | 4 + .../components/dlms_meter/common-generic.yaml | 11 - .../components/dlms_meter/common-netznoe.yaml | 17 - tests/components/dlms_meter/common.yaml | 40 ++ .../components/dlms_meter/test.esp32-ard.yaml | 4 +- .../components/dlms_meter/test.esp32-idf.yaml | 4 +- .../dlms_meter/test.esp8266-ard.yaml | 4 +- .../dlms_meter/test.rp2040-ard.yaml | 4 + 20 files changed, 796 insertions(+), 956 deletions(-) create mode 100644 esphome/components/dlms_meter/binary_sensor/__init__.py delete mode 100644 esphome/components/dlms_meter/dlms.h delete mode 100644 esphome/components/dlms_meter/mbus.h delete mode 100644 esphome/components/dlms_meter/obis.h delete mode 100644 tests/components/dlms_meter/common-generic.yaml delete mode 100644 tests/components/dlms_meter/common-netznoe.yaml create mode 100644 tests/components/dlms_meter/test.rp2040-ard.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 566cac066e..3c1c2be289 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 +fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da diff --git a/CODEOWNERS b/CODEOWNERS index c5beba8c0b..c69f8bccd4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -138,7 +138,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 -esphome/components/dlms_meter/* @SimonFischer04 +esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index c22ab7b552..7094699b0b 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,57 +1,258 @@ -import esphome.codegen as cg -from esphome.components import uart -import esphome.config_validation as cv -from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +import logging +import re -CODEOWNERS = ["@SimonFischer04"] +import esphome.codegen as cg +from esphome.components import esp32, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_PATTERN, + CONF_PRIORITY, + CONF_RECEIVE_TIMEOUT, +) +from esphome.core import CORE + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" +CONF_AUTH_KEY = "auth_key" +CONF_OBIS_CODE = "obis_code" +CONF_CUSTOM_PATTERNS = "custom_patterns" +CONF_SKIP_CRC = "skip_crc" +CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" -PROVIDERS = {"generic": 0, "netznoe": 1} - dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) -def validate_key(value): - value = cv.string_strict(value) - if len(value) != 32: - raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") - try: - return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] - except ValueError as exc: - raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc +def obis_code(value): + # Normalize the OBIS code to the strict A.B.C.D.E.F format + bytes_list = parse_obis_code_bytes(value) + return ".".join(str(b) for b in bytes_list) +def parse_obis_code_bytes(value): + value = cv.string(value) + normalized = re.sub(r"[\-\:\*]", ".", value) + parts = normalized.split(".") + if len(parts) < 5 or len(parts) > 6: + raise cv.Invalid("OBIS code must have 5 or 6 parts") + try: + bytes_list = [int(p) for p in parts] + except ValueError as exc: + raise cv.Invalid("OBIS code parts must be integers") from exc + for b in bytes_list: + if b < 0 or b > 255: + raise cv.Invalid("OBIS code parts must be between 0 and 255") + if len(bytes_list) == 5: + bytes_list.append(255) + return bytes_list + + +def custom_pattern_dict(value): + if isinstance(value, str): + return {CONF_PATTERN: value} + return value + + +def validate_custom_pattern(value): + if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: + raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") + return value + + +def validate_provider_deprecation(config): + if CONF_PROVIDER in config: + provider = str(config[CONF_PROVIDER]).lower() + if provider == "netznoe": + _LOGGER.warning( + "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " + "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" + "Please remove the 'provider' key and explicitly replace it with the following:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n' + ) + patterns = config.get(CONF_CUSTOM_PATTERNS, []) + + # Ensure "L, TSTR" for MeterID is present + if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): + patterns.append( + { + CONF_PATTERN: "L, TSTR", + CONF_NAME: "MeterID", + CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], + CONF_PRIORITY: 0, + } + ) + + # Ensure "F, TDTM" for DateTime is present + if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): + patterns.append( + { + CONF_PATTERN: "F, TDTM", + CONF_NAME: "DateTime", + CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], + CONF_PRIORITY: 0, + } + ) + + config[CONF_CUSTOM_PATTERNS] = patterns + else: + _LOGGER.warning( + "The 'provider' option is deprecated and will be removed in 2026.11.0. " + "The dlms_parser library now handles quirks dynamically. " + "Please remove this option from your configuration." + ) + return config + + +CUSTOM_PATTERN_SCHEMA = cv.All( + custom_pattern_dict, + cv.Schema( + { + cv.Required(CONF_PATTERN): cv.string, + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_, + cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + } + ), + validate_custom_pattern, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Required(CONF_DECRYPTION_KEY): validate_key, - cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( - PROVIDERS, lower=True + cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( + value, name="Decryption key" ), + cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( + value, name="Authentication key" + ), + cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), + cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, + cv.Optional(CONF_PROVIDER): cv.string, + cv.Optional( + CONF_RECEIVE_TIMEOUT, default="1000ms" + ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), + validate_provider_deprecation, ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dlms_meter", baud_rate=2400, require_rx=True -) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + dec_key_expr = cg.RawExpression("std::nullopt") + if dec_key := config.get(CONF_DECRYPTION_KEY): + key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] + dec_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + auth_key_expr = cg.RawExpression("std::nullopt") + if auth_key := config.get(CONF_AUTH_KEY): + key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] + auth_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + patterns = [] + if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): + for p in custom_patterns: + name_expr = cg.RawExpression("std::nullopt") + if name_val := p.get(CONF_NAME): + name_expr = name_val + + if obis_vals := p.get(CONF_DEFAULT_OBIS): + obis_expr = cg.RawExpression( + f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" + ) + else: + obis_expr = cg.RawExpression("std::nullopt") + + patterns.append( + cg.ArrayInitializer( + p[CONF_PATTERN], + name_expr, + p.get(CONF_PRIORITY, 0), + obis_expr, + ) + ) + + patterns_expr = ( + cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") + ) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_RECEIVE_TIMEOUT], + config[CONF_SKIP_CRC], + dec_key_expr, + auth_key_expr, + patterns_expr, + ) + + hub_id = config[CONF_ID].id + + sensor_count = 0 + for sens_conf in CORE.config.get("sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + sensor_count += 1 + else: + from .sensor import NUMERIC_KEYS + + sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) + + text_sensor_count = 0 + for sens_conf in CORE.config.get("text_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + text_sensor_count += 1 + else: + from .text_sensor import TEXT_KEYS + + text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) + + binary_sensor_count = 0 + for sens_conf in CORE.config.get("binary_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + binary_sensor_count += 1 + + cg.add_define("DLMS_MAX_SENSORS", sensor_count) + cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) + cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + await cg.register_component(var, config) await uart.register_uart_device(var, config) - key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) - cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) - cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) + + if CORE.is_esp32: + esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + else: + cg.add_library("esphome/dlms_parser", "1.1.0") diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py new file mode 100644 index 0000000000..f9bc1d9df7 --- /dev/null +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code + +DEPENDENCIES = ["dlms_meter"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h deleted file mode 100644 index a3d8f62ce6..0000000000 --- a/esphome/components/dlms_meter/dlms.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+-------------------------------+ -| Ciphering Service | -+-------------------------------+ -| System Title Length | -+-------------------------------+ -| | -| | -| | -| System | -| Title | -| | -| | -| | -+-------------------------------+ -| Length | (1 or 3 Bytes) -+-------------------------------+ -| Security Control Byte | -+-------------------------------+ -| | -| Frame | -| Counter | -| | -+-------------------------------+ -| | -~ ~ - Encrypted Payload -~ ~ -| | -+-------------------------------+ - -Ciphering Service: 0xDB (General-Glo-Ciphering) -System Title Length: 0x08 -System Title: Unique ID of meter -Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) -Security Control Byte: -- Bit 3…0: Security_Suite_Id -- Bit 4: "A" subfield: indicates that authentication is applied -- Bit 5: "E" subfield: indicates that encryption is applied -- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast -- Bit 7: Indicates the use of compression. - */ - -static constexpr uint8_t DLMS_HEADER_LENGTH = 16; -static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header -static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; -static constexpr uint8_t DLMS_SYST_OFFSET = 1; -static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; -static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; -static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field -static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; -static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; -static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; -static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; -static constexpr uint8_t GLO_CIPHERING = 0xDB; -static constexpr uint8_t DATA_NOTIFICATION = 0x0F; -static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; -static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). - -// Provider specific quirks -static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE -static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; -static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index b732e71d24..bdbf798df5 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,516 +1,236 @@ #include "dlms_meter.h" +#include "esphome/core/log.h" -#include - -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) -#include -#elif defined(USE_ESP32) -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -#include -#else -#include "mbedtls/esp_config.h" -#include "mbedtls/gcm.h" -#endif -#endif +#include namespace esphome::dlms_meter { -static constexpr const char *TAG = "dlms_meter"; +static const char *const TAG = "dlms_meter"; +static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dlms_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} + +DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns) + : receive_timeout_ms_(receive_timeout_ms), + skip_crc_check_(skip_crc_check), + custom_patterns_(std::move(custom_patterns)), + parser_(&decryptor_) { + dlms_parser::Logger::set_log_function(log_callback); + + if (decryption_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); + if (opt_key) { + this->parser_.set_decryption_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); + } +#endif + } + + if (authentication_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); + if (opt_key) { + this->parser_.set_authentication_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); + } +#endif + } + + this->parser_.set_skip_crc_check(this->skip_crc_check_); + + this->parser_.load_default_patterns(); + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, + pattern.default_obis.value()); + } else if (pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); + } else { + this->parser_.register_pattern(pattern.pattern.c_str()); + } + } +} + +void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { - const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; - ESP_LOGCONFIG(TAG, - "DLMS Meter:\n" - " Provider: %s\n" - " Read Timeout: %" PRIu32 " ms", - provider_name, this->read_timeout_); -#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); - DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) -#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) + ESP_LOGCONFIG(TAG, "DLMS Meter:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); + + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + const auto &obis = pattern.default_obis.value(); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", + pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], + obis[3], obis[4], obis[5]); + } else if (pattern.name.has_value()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), + pattern.name->c_str(), pattern.priority); + } else { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); + } + } + +#ifdef USE_SENSOR + for (const auto &entry : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_BINARY_SENSOR + for (const auto &entry : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif } void DlmsMeterComponent::loop() { - // Read while data is available, netznoe uses two frames so allow 2x max frame length - size_t avail = this->available(); - if (avail > 0) { - size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size(); - if (remaining == 0) { - ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); - } else { - // Read all available bytes in batches to reduce UART call overhead. - // Cap reads to remaining buffer capacity. - if (avail > remaining) { - avail = remaining; - } - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read); - this->last_read_ = millis(); - } - } - } - - if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { - this->mbus_payload_.clear(); - if (!this->parse_mbus_(this->mbus_payload_)) - return; - - uint16_t message_length; - uint8_t systitle_length; - uint16_t header_offset; - if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - - if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { - ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); - this->receive_buffer_.clear(); - return; - } - - // Decrypt in place and then decode the OBIS codes - if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + this->read_rx_buffer_(); + if (this->bytes_accumulated_ > 0 && + App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) { + this->process_frame_(); } } -bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { - ESP_LOGV(TAG, "Parsing M-Bus frames"); - uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames - - while (frame_offset < this->receive_buffer_.size()) { - // Ensure enough bytes remain for the minimal intro header before accessing indices - if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { - ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, - (this->receive_buffer_.size() - frame_offset)); - this->receive_buffer_.clear(); - return false; - } - - // Check start bytes - if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || - this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { - ESP_LOGE(TAG, "MBUS: Start bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - // Both length bytes must be identical - if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != - this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { - ESP_LOGE(TAG, "MBUS: Length bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame - - // Check if received data is enough for the given frame length - if (this->receive_buffer_.size() - frame_offset < - frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte - ESP_LOGE(TAG, "MBUS: Frame too big for received data"); - this->receive_buffer_.clear(); - return false; - } - - // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte - size_t required_total = - frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes - if (this->receive_buffer_.size() - frame_offset < required_total) { - ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, - this->receive_buffer_.size() - frame_offset); - this->receive_buffer_.clear(); - return false; - } - - if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != - STOP_BYTE) { - ESP_LOGE(TAG, "MBUS: Invalid stop byte"); - this->receive_buffer_.clear(); - return false; - } - - // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte - uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored - for (uint16_t i = 0; i < frame_length; i++) { - checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; - } - if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { - ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, - this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); - this->receive_buffer_.clear(); - return false; - } - - mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], - &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); - - frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; +void DlmsMeterComponent::flush_rx_buffer_() { + while (this->available()) { + this->read(); } - return true; } -bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, - uint8_t &systitle_length, uint16_t &header_offset) { - ESP_LOGV(TAG, "Parsing DLMS header"); - if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { - ESP_LOGE(TAG, "DLMS: Payload too short"); - this->receive_buffer_.clear(); - return false; +void DlmsMeterComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) + return; + + if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); + this->bytes_accumulated_ = 0; + + this->flush_rx_buffer_(); + return; } - if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) - ESP_LOGE(TAG, "DLMS: Unsupported cipher"); - this->receive_buffer_.clear(); - return false; + bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available); + if (!success) { + ESP_LOGW(TAG, "UART read failed. Dropping frame."); + this->bytes_accumulated_ = 0; + this->flush_rx_buffer_(); + return; } - systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + this->bytes_accumulated_ += available; - if (systitle_length != 0x08) { // Only system titles with length of 8 are supported - ESP_LOGE(TAG, "DLMS: Unsupported system title length"); - this->receive_buffer_.clear(); - return false; - } - - message_length = mbus_payload[DLMS_LENGTH_OFFSET]; - header_offset = 0; - - if (this->provider_ == PROVIDER_NETZNOE) { - // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next - // byte. Check some bytes to see if received data still matches expectation - if (message_length == NETZ_NOE_MAGIC_BYTE && - mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && - mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { - message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; - header_offset = 1; - } else { - ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); - } - } else { - if (message_length == TWO_BYTE_LENGTH) { - message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); - header_offset = DLMS_HEADER_EXT_OFFSET; - } - } - if (message_length < DLMS_LENGTH_CORRECTION) { - ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); - this->receive_buffer_.clear(); - return false; - } - message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length - - if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { - ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), - DLMS_HEADER_LENGTH, header_offset, message_length); - ESP_LOGE(TAG, "DLMS: Message has invalid length"); - this->receive_buffer_.clear(); - return false; - } - - if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && - mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != - 0x20) { // Only certain security suite is supported (0x21 || 0x20) - ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); - this->receive_buffer_.clear(); - return false; - } - - return true; + this->last_rx_char_time_ = App.get_loop_component_start_time(); } -bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset) { - ESP_LOGV(TAG, "Decrypting payload"); - uint8_t iv[12]; // Reserve space for the IV, always 12 bytes - // Copy system title to IV (System title is before length; no header offset needed!) - // Add 1 to the offset in order to skip the system title length byte - memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); - memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], - DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV +void DlmsMeterComponent::process_frame_() { + ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + this->on_data_(obis_code, float_val, str_val, is_numeric); + }; -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) - br_gcm_context gcm_ctx; - br_aes_ct_ctr_keys bc; - br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); - br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); - br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); - br_gcm_flip(&gcm_ctx); - br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); -#elif defined(USE_ESP32) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) - // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); - mbedtls_svc_key_id_t key_id; - bool decrypt_failed = true; - if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { - psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; - if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && - psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { - size_t outlen = 0; - if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && - outlen == message_length) { - decrypt_failed = false; + this->bytes_accumulated_ = 0; +} + +void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (auto &item : this->sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(float_val); + updated_count++; } } - psa_aead_abort(&op); - psa_destroy_key(key_id); - } - if (decrypt_failed) { - ESP_LOGE(TAG, "Decryption failed"); - this->receive_buffer_.clear(); - return false; - } -#else - size_t outlen = 0; - mbedtls_gcm_context gcm_ctx; - mbedtls_gcm_init(&gcm_ctx); - mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); - mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); - auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); - mbedtls_gcm_free(&gcm_ctx); - if (ret != 0) { - ESP_LOGE(TAG, "Decryption failed with error: %d", ret); - this->receive_buffer_.clear(); - return false; } #endif -#else -#error "Invalid Platform" + +#ifdef USE_TEXT_SENSOR + if (!is_numeric && str_val != nullptr) { + for (auto &item : this->text_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(str_val); + updated_count++; + } + } + } #endif - if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { - ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); - this->receive_buffer_.clear(); - return false; - } - ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); - return true; -} - -void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { - ESP_LOGV(TAG, "Decoding payload"); - MeterData data{}; - uint16_t current_position = DECODER_START_OFFSET; - bool power_factor_found = false; - - while (current_position + OBIS_CODE_OFFSET <= message_length) { - if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); - this->receive_buffer_.clear(); - return; - } - - uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; - if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); - this->receive_buffer_.clear(); - return; - } - if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); - this->receive_buffer_.clear(); - return; - } - - uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; - uint8_t obis_medium = obis_code[OBIS_A]; - uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); - - bool timestamp_found = false; - bool meter_number_found = false; - if (this->provider_ == PROVIDER_NETZNOE) { - // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET - if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { - timestamp_found = true; - } else if (power_factor_found) { - meter_number_found = true; - power_factor_found = false; - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position - } - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type - } - if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && - obis_medium != Medium::ABSTRACT) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); - this->receive_buffer_.clear(); - return; - } - - if (current_position >= message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); - this->receive_buffer_.clear(); - return; - } - - float value = 0.0f; - uint8_t value_size = 0; - uint8_t data_type = plaintext[current_position]; - current_position++; - - switch (data_type) { - case DataType::DOUBLE_LONG_UNSIGNED: { - value_size = 4; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], - plaintext[current_position + 2], plaintext[current_position + 3]); - current_position += value_size; - break; - } - case DataType::LONG_UNSIGNED: { - value_size = 2; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - current_position += value_size; - break; - } - case DataType::OCTET_STRING: { - uint8_t data_length = plaintext[current_position]; - current_position++; // Advance past string length - if (current_position + data_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); - this->receive_buffer_.clear(); - return; - } - // Handle timestamp (normal OBIS code or NETZNOE special case) - if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { - if (data_length < 8) { - ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); - this->receive_buffer_.clear(); - return; - } - uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - uint8_t month = plaintext[current_position + 2]; - uint8_t day = plaintext[current_position + 3]; - uint8_t hour = plaintext[current_position + 5]; - uint8_t minute = plaintext[current_position + 6]; - uint8_t second = plaintext[current_position + 7]; - if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { - ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, - second); - this->receive_buffer_.clear(); - return; - } - snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, - minute, second); - } else if (meter_number_found) { - snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); - } - current_position += data_length; - break; - } - default: - ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); - this->receive_buffer_.clear(); - return; - } - - // Skip break after data - if (this->provider_ == PROVIDER_NETZNOE) { - // Don't skip the break on the first timestamp, as there's none - if (!timestamp_found) { - current_position += 2; - } - } else { - current_position += 2; - } - - // Check for additional data (scaler-unit structure) - if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { - // Apply scaler: real_value = raw_value × 10^scaler - if (current_position + 1 < message_length) { - int8_t scaler = static_cast(plaintext[current_position + 1]); - if (scaler != 0) { - value *= pow10_int(scaler); - } - } - - // on EVN Meters there is no additional break - if (this->provider_ == PROVIDER_NETZNOE) { - current_position += 4; - } else { - current_position += 6; - } - } - - // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) - if (value_size > 0) { - switch (obis_cd) { - case OBIS_VOLTAGE_L1: - data.voltage_l1 = value; - break; - case OBIS_VOLTAGE_L2: - data.voltage_l2 = value; - break; - case OBIS_VOLTAGE_L3: - data.voltage_l3 = value; - break; - case OBIS_CURRENT_L1: - data.current_l1 = value; - break; - case OBIS_CURRENT_L2: - data.current_l2 = value; - break; - case OBIS_CURRENT_L3: - data.current_l3 = value; - break; - case OBIS_ACTIVE_POWER_PLUS: - data.active_power_plus = value; - break; - case OBIS_ACTIVE_POWER_MINUS: - data.active_power_minus = value; - break; - case OBIS_ACTIVE_ENERGY_PLUS: - data.active_energy_plus = value; - break; - case OBIS_ACTIVE_ENERGY_MINUS: - data.active_energy_minus = value; - break; - case OBIS_REACTIVE_ENERGY_PLUS: - data.reactive_energy_plus = value; - break; - case OBIS_REACTIVE_ENERGY_MINUS: - data.reactive_energy_minus = value; - break; - case OBIS_POWER_FACTOR: - data.power_factor = value; - power_factor_found = true; - break; - default: - ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (auto &item : this->binary_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(state); + updated_count++; } } } +#endif - this->receive_buffer_.clear(); - - ESP_LOGI(TAG, "Received valid data"); - this->publish_sensors(data); - this->status_clear_warning(); + if (updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + } } +#ifdef USE_SENSOR +void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_TEXT_SENSOR +void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_BINARY_SENSOR +void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index c50e6f6b4d..cdc53d5685 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -2,95 +2,150 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif -#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif -#include "mbus.h" -#include "dlms.h" -#include "obis.h" +#include -#include #include +#include +#include +#include +#include + +#if __has_include() +#include +#elif !defined(USE_ESP8266) && __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#define DLMS_METER_NO_CRYPTO +#endif + +#ifndef DLMS_MAX_SENSORS +static constexpr uint8_t DLMS_MAX_SENSORS = 0; +#endif +#ifndef DLMS_MAX_TEXT_SENSORS +static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; +#endif +#ifndef DLMS_MAX_BINARY_SENSORS +static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; +#endif namespace esphome::dlms_meter { -#ifndef DLMS_METER_SENSOR_LIST -#define DLMS_METER_SENSOR_LIST(F, SEP) -#endif - -#ifndef DLMS_METER_TEXT_SENSOR_LIST -#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) -#endif - -struct MeterData { - float voltage_l1 = 0.0f; // Voltage L1 - float voltage_l2 = 0.0f; // Voltage L2 - float voltage_l3 = 0.0f; // Voltage L3 - float current_l1 = 0.0f; // Current L1 - float current_l2 = 0.0f; // Current L2 - float current_l3 = 0.0f; // Current L3 - float active_power_plus = 0.0f; // Active power taken from grid - float active_power_minus = 0.0f; // Active power put into grid - float active_energy_plus = 0.0f; // Active energy taken from grid - float active_energy_minus = 0.0f; // Active energy put into grid - float reactive_energy_plus = 0.0f; // Reactive energy taken from grid - float reactive_energy_minus = 0.0f; // Reactive energy put into grid - char timestamp[27]{}; // Text sensor for the timestamp value - - // Netz NOE - float power_factor = 0.0f; // Power Factor - char meternumber[13]{}; // Text sensor for the meterNumber value +#ifdef DLMS_METER_NO_CRYPTO +// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) +class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { + public: + void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} + bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, + std::span aad, std::span tag) override { + return false; + } }; +#endif -// Provider constants -enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#elif __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#else +using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; +#endif + +#ifdef USE_SENSOR +struct SensorItem { + std::string obis_code; + sensor::Sensor *sensor; +}; +#endif +#ifdef USE_TEXT_SENSOR +struct TextSensorItem { + std::string obis_code; + text_sensor::TextSensor *sensor; +}; +#endif +#ifdef USE_BINARY_SENSOR +struct BinarySensorItem { + std::string obis_code; + binary_sensor::BinarySensor *sensor; +}; +#endif + +struct CustomPattern { + std::string pattern; + std::optional name; + int priority{0}; + std::optional> default_obis; +}; class DlmsMeterComponent : public Component, public uart::UARTDevice { public: - DlmsMeterComponent() = default; + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns); + void setup() override; void dump_config() override; void loop() override; - void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } - void set_provider(uint32_t provider) { this->provider_ = provider; } - - void publish_sensors(MeterData &data) { -#define DLMS_METER_PUBLISH_SENSOR(s) \ - if (this->s##_sensor_ != nullptr) \ - s##_sensor_->publish_state(data.s); - DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) - -#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ - if (this->s##_text_sensor_ != nullptr) \ - s##_text_sensor_->publish_state(data.s); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) - } - - DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) - DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) +#ifdef USE_SENSOR + void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); +#endif +#ifdef USE_TEXT_SENSOR + void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); +#endif +#ifdef USE_BINARY_SENSOR + void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); +#endif protected: - bool parse_mbus_(std::vector &mbus_payload); - bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, - uint16_t &header_offset); - bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset); - void decode_obis_(uint8_t *plaintext, uint16_t message_length); + void read_rx_buffer_(); + void flush_rx_buffer_(); + void process_frame_(); + void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); - std::vector receive_buffer_; // Stores the packet currently being received - std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn - uint32_t last_read_ = 0; // Timestamp when data was last read - uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + std::array rx_buffer_; + size_t bytes_accumulated_{0}; + uint32_t last_rx_char_time_{0}; - uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator - std::array decryption_key_; + uint32_t receive_timeout_ms_{1000}; + bool skip_crc_check_{false}; + + std::vector custom_patterns_; + + Aes128GcmDecryptorImpl decryptor_; + dlms_parser::DlmsParser parser_; + +#ifdef USE_SENSOR + StaticVector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + StaticVector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + StaticVector binary_sensors_; +#endif }; } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h deleted file mode 100644 index 293d43a55b..0000000000 --- a/esphome/components/dlms_meter/mbus.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+----------------------------------------------------+ - -| Start Character [0x68] | \ -+----------------------------------------------------+ | -| Data Length (L) | | -+----------------------------------------------------+ | -| Data Length Repeat (L) | | -+----------------------------------------------------+ > M-Bus Data link layer -| Start Character Repeat [0x68] | | -+----------------------------------------------------+ | -| Control/Function Field (C) | | -+----------------------------------------------------+ | -| Address Field (A) | / -+----------------------------------------------------+ - -| Control Information Field (CI) | \ -+----------------------------------------------------+ | -| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer -+----------------------------------------------------+ | -| Destination Transport Service Access Point (DTSAP) | / -+----------------------------------------------------+ - -| | \ -~ ~ | - Data > DLMS/COSEM Application Layer -~ ~ | -| | / -+----------------------------------------------------+ - -| Checksum | \ -+----------------------------------------------------+ > M-Bus Data link layer -| Stop Character [0x16] | / -+----------------------------------------------------+ - - -Data_Length = L - C - A - CI -Each line (except Data) is one Byte - -Possible Values found in publicly available docs: -- C: 0x53/0x73 (SND_UD) -- A: FF (Broadcast) -- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D -- STSAP: 0x01 (Management Logical Device ID 1 of the meter) -- DTSAP: 0x67 (Consumer Information Push Client ID 103) - */ - -// MBUS start bytes for different telegram formats: -// - Single Character: 0xE5 (length=1) -// - Short Frame: 0x10 (length=5) -// - Control Frame: 0x68 (length=9) -// - Long Frame: 0x68 (length=9+data_length) -// This component currently only uses Long Frame. -static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; -static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; -static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; -static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; -static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) -static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length -static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame -static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame -static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte -static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte -static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte -static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte -static constexpr uint8_t STOP_BYTE = 0x16; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h deleted file mode 100644 index 1bb960e61e..0000000000 --- a/esphome/components/dlms_meter/obis.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -// Data types as per specification -enum DataType { - NULL_DATA = 0x00, - BOOLEAN = 0x03, - BIT_STRING = 0x04, - DOUBLE_LONG = 0x05, - DOUBLE_LONG_UNSIGNED = 0x06, - OCTET_STRING = 0x09, - VISIBLE_STRING = 0x0A, - UTF8_STRING = 0x0C, - BINARY_CODED_DECIMAL = 0x0D, - INTEGER = 0x0F, - LONG = 0x10, - UNSIGNED = 0x11, - LONG_UNSIGNED = 0x12, - LONG64 = 0x14, - LONG64_UNSIGNED = 0x15, - ENUM = 0x16, - FLOAT32 = 0x17, - FLOAT64 = 0x18, - DATE_TIME = 0x19, - DATE = 0x1A, - TIME = 0x1B, - - ARRAY = 0x01, - STRUCTURE = 0x02, - COMPACT_ARRAY = 0x13 -}; - -enum Medium { - ABSTRACT = 0x00, - ELECTRICITY = 0x01, - HEAT_COST_ALLOCATOR = 0x04, - COOLING = 0x05, - HEAT = 0x06, - GAS = 0x07, - COLD_WATER = 0x08, - HOT_WATER = 0x09, - OIL = 0x10, - COMPRESSED_AIR = 0x11, - NITROGEN = 0x12 -}; - -// Data structure -static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block -static constexpr uint8_t OBIS_TYPE_OFFSET = 0; -static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; -static constexpr uint8_t OBIS_CODE_OFFSET = 2; -static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) -static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code -static constexpr uint8_t OBIS_A = 0; -static constexpr uint8_t OBIS_B = 1; -static constexpr uint8_t OBIS_C = 2; -static constexpr uint8_t OBIS_D = 3; -static constexpr uint8_t OBIS_E = 4; -static constexpr uint8_t OBIS_F = 5; - -// Metadata -static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; -static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; -static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; - -// Voltage -static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; -static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; -static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; - -// Current -static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; -static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; -static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; - -// Power -static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; -static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; - -// Active energy -static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; -static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; - -// Reactive energy -static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; -static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; - -// Netz NOE specific -static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 27fd44f008..ec4639351d 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,8 +1,9 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -16,109 +17,142 @@ from esphome.const import ( UNIT_WATT_HOURS, ) -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +NUMERIC_KEYS = { + "voltage_l1": "1.0.32.7.0.255", + "voltage_l2": "1.0.52.7.0.255", + "voltage_l3": "1.0.72.7.0.255", + "current_l1": "1.0.31.7.0.255", + "current_l2": "1.0.51.7.0.255", + "current_l3": "1.0.71.7.0.255", + "active_power_plus": "1.0.1.7.0.255", + "active_power_minus": "1.0.2.7.0.255", + "active_energy_plus": "1.0.1.8.0.255", + "active_energy_minus": "1.0.2.8.0.255", + "reactive_energy_plus": "1.0.3.8.0.255", + "reactive_energy_minus": "1.0.4.8.0.255", + "power_factor": "1.0.13.7.0.255", +} + +DYNAMIC_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - # Netz NOE - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == sensor.Sensor: - sens = await sensor.new_sensor(conf) - cg.add(getattr(hub, f"set_{key}_sensor")(sens)) - sensors.append(f"F({key})") - - if sensors: - cg.add_define( - "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) - ) + if obis := config.get(CONF_OBIS_CODE): + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(obis, var)) + else: + for key, obis_val in NUMERIC_KEYS.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(hub.register_sensor(obis_val, sens)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 4d2373f4f9..0bfb43a285 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,37 +1,59 @@ +import logging + import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +TEXT_KEYS = { + "timestamp": "0.0.1.0.0.255", + "meternumber": "0.0.96.1.0.255", +} + +DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - # Netz NOE - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - text_sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == text_sensor.TextSensor: - sens = await text_sensor.new_text_sensor(conf) - cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) - text_sensors.append(f"F({key})") - - if text_sensors: - cg.add_define( - "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", - cg.RawExpression(" sep ".join(text_sensors)), - ) + if obis := config.get(CONF_OBIS_CODE): + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(obis, var)) + else: + for key, obis_val in TEXT_KEYS.items(): + if text_sensor_config := config.get(key): + sens = await text_sensor.new_text_sensor(text_sensor_config) + cg.add(hub.register_text_sensor(obis_val, sens)) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4a4bc18579..7cbc2ac4ae 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,8 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" + esphome/dlms_parser: + version: 1.1.0 esphome/esp-audio-libs: version: 3.2.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index b41e850bcd..d60a4fd68d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -107,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) @@ -193,6 +194,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json @@ -212,6 +214,7 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard @@ -236,6 +239,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json lvgl/lvgl@9.5.0 ; lvgl diff --git a/tests/components/dlms_meter/common-generic.yaml b/tests/components/dlms_meter/common-generic.yaml deleted file mode 100644 index edb1c66f0f..0000000000 --- a/tests/components/dlms_meter/common-generic.yaml +++ /dev/null @@ -1,11 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - -sensor: - - platform: dlms_meter - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-netznoe.yaml b/tests/components/dlms_meter/common-netznoe.yaml deleted file mode 100644 index db064b64f9..0000000000 --- a/tests/components/dlms_meter/common-netznoe.yaml +++ /dev/null @@ -1,17 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - provider: netznoe # (optional) key - only set if using evn - -sensor: - - platform: dlms_meter - # EVN - power_factor: - name: "Power Factor" - -text_sensor: - - platform: dlms_meter - # EVN - meternumber: - name: "meterNumber" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 6aa4e1b0ff..59d854a3ae 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -1,4 +1,16 @@ +dlms_meter: + id: dlms_meter_hub + receive_timeout: 50ms + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" + auth_key: "11223344556677889900AABBCCDDEEFF" + skip_crc: true + provider: "netznoe" + custom_patterns: + - "custom_pattern_1" + - "custom_pattern_2" + sensor: + # Old Schema tests - platform: dlms_meter voltage_l1: name: "Voltage L1" @@ -20,8 +32,36 @@ sensor: name: "Active energy taken from grid" active_energy_minus: name: "Active energy put into grid" + reactive_energy_plus: + name: "Reactive energy taken from grid" + reactive_energy_minus: + name: "Reactive energy put into grid" + power_factor: + name: "Power factor" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "1-0:99.99.9" + name: "Custom Dynamic Sensor" text_sensor: + # Old Schema tests - platform: dlms_meter timestamp: name: "timestamp" + meternumber: + name: "Meter Number" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-0:99.99.9" + name: "Custom Dynamic Text Sensor" + +binary_sensor: + # Dynamic Schema tests (Binary sensors only use the dynamic schema) + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-1:2.3.4" + name: "Custom Binary Sensor" diff --git a/tests/components/dlms_meter/test.esp32-ard.yaml b/tests/components/dlms_meter/test.esp32-ard.yaml index c9910aa600..bd11a44373 100644 --- a/tests/components/dlms_meter/test.esp32-ard.yaml +++ b/tests/components/dlms_meter/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml + uart: !include ../../test_build_components/common/uart/esp32-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp32-idf.yaml b/tests/components/dlms_meter/test.esp32-idf.yaml index 1547532f1e..2d29656c94 100644 --- a/tests/components/dlms_meter/test.esp32-idf.yaml +++ b/tests/components/dlms_meter/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml -<<: !include common-netznoe.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp8266-ard.yaml b/tests/components/dlms_meter/test.esp8266-ard.yaml index 119a1978de..5a05efa259 100644 --- a/tests/components/dlms_meter/test.esp8266-ard.yaml +++ b/tests/components/dlms_meter/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.rp2040-ard.yaml b/tests/components/dlms_meter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/dlms_meter/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml From 2310b9e3fe83c9765cc4a7f5eb9d8039b9b4da33 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 9 Jun 2026 20:27:37 +0200 Subject: [PATCH 268/282] [usb_uart] Add Prolific PL2303 USB-serial driver (#16885) --- esphome/components/usb_uart/__init__.py | 9 +- esphome/components/usb_uart/pl2303.cpp | 298 ++++++++++++++++++++++++ esphome/components/usb_uart/usb_uart.h | 25 ++ tests/components/usb_uart/common.yaml | 13 ++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 esphome/components/usb_uart/pl2303.cpp diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 7b9c320879..e42a2c092b 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -58,13 +58,20 @@ class Type: uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), Type("FT232", 0x0403, 0x6001, "FT23XX", 1), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp new file mode 100644 index 0000000000..a50f1cf2d4 --- /dev/null +++ b/esphome/components/usb_uart/pl2303.cpp @@ -0,0 +1,298 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" + +namespace esphome::usb_uart { + +// Control request types +static constexpr uint8_t SET_LINE_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_LINE_REQUEST = 0x20; + +static constexpr uint8_t SET_CONTROL_REQUEST_TYPE = 0x21; +static constexpr uint8_t SET_CONTROL_REQUEST = 0x22; +static constexpr uint8_t CONTROL_DTR = 0x01; +static constexpr uint8_t CONTROL_RTS = 0x02; + +static constexpr uint8_t VENDOR_WRITE_REQUEST_TYPE = 0x40; +static constexpr uint8_t VENDOR_WRITE_REQUEST = 0x01; + +static constexpr uint8_t VENDOR_READ_REQUEST_TYPE = 0xc0; +static constexpr uint8_t VENDOR_READ_REQUEST = 0x01; + +// Supported standard baud rates for direct encoding (TYPE_H, TYPE_HX, TYPE_HXD, TYPE_HXN) +static const uint32_t SUPPORTED_BAUD_RATES[] = { + 75, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 14400, 19200, + 28800, 38400, 57600, 115200, 230400, 460800, 614400, 921600, 1228800, 2457600, 3000000, 6000000, +}; + +static const char *pl2303_type_name(Pl2303ChipType type) { + switch (type) { + case PL2303_TYPE_H: + return "H (legacy)"; + case PL2303_TYPE_HX: + return "HX"; + case PL2303_TYPE_TA: + return "TA"; + case PL2303_TYPE_TB: + return "TB"; + case PL2303_TYPE_HXD: + return "HXD"; + case PL2303_TYPE_HXN: + return "G/HXN (newer)"; + default: + return "unknown"; + } +} + +// Find nearest supported baud rate for direct encoding +static uint32_t nearest_supported_baud(uint32_t baud) { + size_t n = sizeof(SUPPORTED_BAUD_RATES) / sizeof(SUPPORTED_BAUD_RATES[0]); + for (size_t i = 0; i < n; i++) { + if (SUPPORTED_BAUD_RATES[i] > baud) { + if (i == 0) + return SUPPORTED_BAUD_RATES[0]; + uint32_t lower = SUPPORTED_BAUD_RATES[i - 1]; + uint32_t upper = SUPPORTED_BAUD_RATES[i]; + return (upper - baud) > (baud - lower) ? lower : upper; + } + } + return SUPPORTED_BAUD_RATES[n - 1]; +} + +// Direct encoding: little-endian 32-bit baud rate value +static void encode_baud_direct(uint8_t buf[4], uint32_t baud) { + buf[0] = baud & 0xFF; + buf[1] = (baud >> 8) & 0xFF; + buf[2] = (baud >> 16) & 0xFF; + buf[3] = (baud >> 24) & 0xFF; +} + +// Divisor encoding for TYPE_HX, TYPE_HXD: baudrate = 12M*32 / (mantissa * 4^exponent) +static void encode_baud_divisor(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 512) { + if (exponent < 7) { + mantissa >>= 2; + exponent++; + } else { + mantissa = 511; + break; + } + } + buf[3] = 0x80; + buf[2] = 0; + buf[1] = (exponent << 1) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +// Alt divisor encoding for TYPE_TA, TYPE_TB: baudrate = 12M*32 / (mantissa * 2^exponent) +static void encode_baud_divisor_alt(uint8_t buf[4], uint32_t baud) { + static constexpr uint32_t BASELINE = 12000000 * 32; + uint32_t mantissa = BASELINE / baud; + if (mantissa == 0) + mantissa = 1; + uint8_t exponent = 0; + while (mantissa >= 2048) { + if (exponent < 15) { + mantissa >>= 1; + exponent++; + } else { + mantissa = 2047; + break; + } + } + buf[3] = 0x80; + buf[2] = exponent & 0x01; + buf[1] = ((exponent & ~0x01) << 4) | (mantissa >> 8); + buf[0] = mantissa & 0xFF; +} + +std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + std::vector cdc_devs{}; + + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "PL2303: get_active_config_descriptor failed"); + return {}; + } + + // Detect chip type from USB descriptor fields (mirrors pl2303_detect_type in Linux driver) + uint16_t bcd_device = device_desc->bcdDevice; + uint16_t bcd_usb = device_desc->bcdUSB; + uint8_t bmax_packet = device_desc->bMaxPacketSize0; + uint8_t bdev_class = device_desc->bDeviceClass; + + if (bdev_class == 0x02 || bmax_packet != 0x40) { + this->chip_type_ = PL2303_TYPE_H; + } else { + switch (bcd_usb) { + case 0x0101: + case 0x0110: + this->chip_type_ = (bcd_device == 0x0400) ? PL2303_TYPE_HXD : PL2303_TYPE_HX; + break; + default: + // TA and TB are distinguishable by bcdDevice without any USB probe. + if (bcd_device == 0x0300) { + this->chip_type_ = PL2303_TYPE_TA; + } else if (bcd_device == 0x0500) { + this->chip_type_ = PL2303_TYPE_TB; + } else { + this->chip_type_ = PL2303_TYPE_HXN; + } + break; + } + } + + ESP_LOGI(TAG, "PL2303 chip type: %s (bcdUSB=0x%04X bcdDevice=0x%04X bMaxPkt=%u)", pl2303_type_name(this->chip_type_), + bcd_usb, bcd_device, bmax_packet); + + // PL2303 is single-port: find first interface with 2 bulk endpoints + int conf_offset = 0; + for (uint8_t i = 0; i < config_desc->bNumInterfaces; i++) { + int ep_offset = conf_offset; + const auto *intf = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); + if (!intf) + break; + if (intf->bNumEndpoints < 2) + continue; + + const usb_ep_desc_t *in_ep = nullptr; + const usb_ep_desc_t *out_ep = nullptr; + const usb_ep_desc_t *notify_ep = nullptr; + + for (uint8_t e = 0; e < intf->bNumEndpoints; e++) { + ep_offset = conf_offset; + const auto *ep = usb_parse_endpoint_descriptor_by_index(intf, e, config_desc->wTotalLength, &ep_offset); + if (!ep) + break; + if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK) { + if (ep->bEndpointAddress & usb_host::USB_DIR_IN) { + in_ep = ep; + } else { + out_ep = ep; + } + } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { + notify_ep = ep; + } + } + + if (in_ep && out_ep) { + cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + break; // PL2303 is single-port + } + } + + if (cdc_devs.empty()) + ESP_LOGE(TAG, "PL2303: failed to find bulk IN+OUT endpoints"); + + return cdc_devs; +} + +void USBUartTypePL2303::enable_channels() { + if (this->channels_.empty()) + return; + + auto *channel = this->channels_[0]; + bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); + bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); + + usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { + if (!status.success) + ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); + }; + + // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): + // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, + // write 0x0404=1, read 0x8484, read 0x8383, + // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) + if (!is_hxn) { + uint8_t req = VENDOR_READ_REQUEST; + uint8_t wreq = VENDOR_WRITE_REQUEST; + + // Fire-and-forget vendor reads: result discarded, chip requires this sequence. + // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); + this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); + this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + } + + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); + + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } + + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + uint16_t iface = channel->cdc_dev_.bulk_interface_number; + this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + + // Assert DTR + RTS + this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + + this->start_channels_(); +} + +} // namespace esphome::usb_uart +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 41dc2c546d..d0dccf42b9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,6 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; class USBUartChannel; +class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -130,6 +131,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented parse_descriptors(usb_device_handle_t dev_hdl) override; + void enable_channels() override; + + Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; +}; + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index c8c1ee7df2..5b23f9d685 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -52,3 +52,16 @@ usb_uart: stop_bits: 2 data_bits: 7 parity: odd + - id: uart_7 + type: pl2303 + channels: + - id: channel_7_1 + baud_rate: 115200 + - id: uart_8 + type: pl2303gc + channels: + - id: channel_8_1 + baud_rate: 9600 + stop_bits: 2 + data_bits: 7 + parity: even From 7533835e044a2148a68e60fd7fe85776037a7acf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:03:31 -0400 Subject: [PATCH 269/282] Bump py7zr from 0.22.0 to 1.1.0 (#16901) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ed7f2c2941..62ed506e36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==0.22.0 +py7zr==1.1.0 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From eb6d6eac7dd1eaf274d733d236bb5cfafea5bdc6 Mon Sep 17 00:00:00 2001 From: Ricky Tsai <49546657+RT530@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:58:02 +1200 Subject: [PATCH 270/282] [xdb401] XDB401 Pressure Sensor (#15108) Co-authored-by: Ricky Tsai Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/xdb401/__init__.py | 1 + esphome/components/xdb401/sensor.py | 64 +++++++ esphome/components/xdb401/xdb401.cpp | 177 ++++++++++++++++++ esphome/components/xdb401/xdb401.h | 37 ++++ tests/components/xdb401/common.yaml | 10 + tests/components/xdb401/test.esp32-idf.yaml | 4 + tests/components/xdb401/test.esp8266-ard.yaml | 4 + 8 files changed, 298 insertions(+) create mode 100644 esphome/components/xdb401/__init__.py create mode 100644 esphome/components/xdb401/sensor.py create mode 100644 esphome/components/xdb401/xdb401.cpp create mode 100644 esphome/components/xdb401/xdb401.h create mode 100644 tests/components/xdb401/common.yaml create mode 100644 tests/components/xdb401/test.esp32-idf.yaml create mode 100644 tests/components/xdb401/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c69f8bccd4..a64d6f3daf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -596,6 +596,7 @@ esphome/components/wk2212_spi/* @DrCoolZic esphome/components/wl_134/* @hobbypunk90 esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD +esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 diff --git a/esphome/components/xdb401/__init__.py b/esphome/components/xdb401/__init__.py new file mode 100644 index 0000000000..943139e19a --- /dev/null +++ b/esphome/components/xdb401/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@RT530"] diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py new file mode 100644 index 0000000000..7545343f02 --- /dev/null +++ b/esphome/components/xdb401/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_PRESSURE, + CONF_TEMPERATURE, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PASCAL, +) + +DEPENDENCIES = ["i2c"] + +CONF_PRESSURE_RANGE_BAR = "pressure_range_bar" + +xdb401_ns = cg.esphome_ns.namespace("xdb401") + +XDB401Component = xdb401_ns.class_( + "XDB401Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XDB401Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_PASCAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE_RANGE_BAR, default=10): cv.one_of( + 1, 2, 5, 10, 20, 50, 100, int=True + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x7F)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature_sensor(sens)) + + if pressure_config := config.get(CONF_PRESSURE): + sens = await sensor.new_sensor(pressure_config) + cg.add(var.set_pressure_sensor(sens)) diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp new file mode 100644 index 0000000000..3a24d63760 --- /dev/null +++ b/esphome/components/xdb401/xdb401.cpp @@ -0,0 +1,177 @@ +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "xdb401.h" + +namespace esphome::xdb401 { + +static const char *const TAG = "xdb401"; + +static const uint8_t REG_PRESSURE = 0x06; +static const uint8_t REG_TEMPERATURE = 0x09; +static const uint8_t REG_MAKE_MEASURE = 0x30; +static const uint8_t CMD_MAKE_MEASURE = 0x0A; +static const uint8_t MASK_MEASURE_READY = 0x08; +static const float CONVERT_PRESSURE = 8388608.0f; // 0x800000 + +static const uint32_t CHECK_DELAY = 5; +static const uint8_t CHECK_ATTEMPTS = 6; +static const uint8_t MARK_FAIL_AFTER = 5; + +void XDB401Component::setup() { + ESP_LOGCONFIG(TAG, "Running setup"); + + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("I2C communication failed")); + return; + } + + this->comm_err_counter_ = 0; +} + +void XDB401Component::dump_config() { + ESP_LOGCONFIG(TAG, "XDB401:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Pressure Range: %u bar", this->pressure_range_bar_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +void XDB401Component::handle_comm_failure_(const char *message) { + this->status_set_warning(message); + + if (this->comm_err_counter_ >= MARK_FAIL_AFTER) { + this->mark_failed(LOG_STR("Too many consecutive I2C communication errors")); + } else { + this->comm_err_counter_++; + } + + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::start_measurement_() { + i2c::ErrorCode err_code = this->write_register(REG_MAKE_MEASURE, &CMD_MAKE_MEASURE, sizeof(CMD_MAKE_MEASURE)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error starting measurement, code: %u", err_code); + return err_code; + } + + return i2c::ERROR_OK; +} + +void XDB401Component::check_measurement_ready_(uint8_t attempt) { + uint8_t meas_resp[1] = {}; + i2c::ErrorCode err_code = this->read_register(REG_MAKE_MEASURE, meas_resp, sizeof(meas_resp)); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading measurement status, code: %u", err_code); + this->handle_comm_failure_("I2C communication failed"); + return; + } + + ESP_LOGV(TAG, "Config response %02X", meas_resp[0]); + + // Bit 3 shall be 0 when measurement is ready + if ((meas_resp[0] & MASK_MEASURE_READY) == 0) { + ESP_LOGV(TAG, "Meas mode entered after %u ms", attempt * CHECK_DELAY); + this->read_measurement_(); + return; + } + + if (attempt >= CHECK_ATTEMPTS) { + ESP_LOGE(TAG, "Device not in measurement mode after timeout of %u ms", CHECK_DELAY * CHECK_ATTEMPTS); + this->handle_comm_failure_("Measurement timeout"); + return; + } + + this->set_timeout(CHECK_DELAY, [this, attempt]() { this->check_measurement_ready_(attempt + 1); }); +} + +void XDB401Component::read_measurement_() { + float temperature{}; + float pressure{}; + + i2c::ErrorCode err_code = this->read_pressure_(pressure); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read pressure data"); + return; + } + + err_code = this->read_temperature_(temperature); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("Could not read temperature data"); + return; + } + + ESP_LOGD(TAG, "Got pressure=%.1f Pa, temperature=%.2f°C", pressure, temperature); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->pressure_sensor_ != nullptr) + this->pressure_sensor_->publish_state(pressure); + + this->comm_err_counter_ = 0; + this->status_clear_warning(); + this->measurement_in_progress_ = false; +} + +i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { + uint8_t p_data[3]{}; + i2c::ErrorCode err_code = this->read_register(REG_PRESSURE, p_data, 3); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading pressure register"); + return err_code; + } + char pressure_buf[format_hex_pretty_size(3)]; + format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); + ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); + + // Sign-extend 24-bit big-endian pressure value to int32_t. + int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + ESP_LOGD(TAG, "Pressure data raw %i", raw_pressure); + + pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * + XDB401Component::full_scale_pressure_pa(this->pressure_range_bar_); + + return err_code; +} + +i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { + uint8_t t_data[2]{}; + i2c::ErrorCode err_code = this->read_register(REG_TEMPERATURE, t_data, 2); + if (err_code != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Error reading temperature register"); + return err_code; + } + + char temperature_buf[format_hex_pretty_size(2)]; + format_hex_pretty_to(temperature_buf, sizeof(temperature_buf), t_data, 2); + ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); + + // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). + int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); + + temperature = static_cast(raw_temperature) / 256.0f; + + return err_code; +} + +void XDB401Component::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "Skipping update, measurement already in progress"); + return; + } + + i2c::ErrorCode err_code = this->start_measurement_(); + if (err_code != i2c::ERROR_OK) { + this->handle_comm_failure_("I2C communication failed"); + return; + } + + this->measurement_in_progress_ = true; + this->set_timeout(CHECK_DELAY, [this]() { this->check_measurement_ready_(1); }); +} + +} // namespace esphome::xdb401 diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h new file mode 100644 index 0000000000..674d26fe8e --- /dev/null +++ b/esphome/components/xdb401/xdb401.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::xdb401 { + +class XDB401Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } + void set_pressure_range_bar(uint8_t pressure_range_bar) { this->pressure_range_bar_ = pressure_range_bar; } + + void setup() override; + void dump_config() override; + void update() override; + + protected: + void handle_comm_failure_(const char *message); + i2c::ErrorCode start_measurement_(); + void check_measurement_ready_(uint8_t attempt); + void read_measurement_(); + i2c::ErrorCode read_pressure_(float &pressure); + i2c::ErrorCode read_temperature_(float &temperature); + + static constexpr float full_scale_pressure_pa(uint8_t pressure_range_bar) { return pressure_range_bar * 100000.0f; } + + uint8_t comm_err_counter_{0}; + bool measurement_in_progress_{false}; + uint8_t pressure_range_bar_{10}; + + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *pressure_sensor_{nullptr}; +}; + +} // namespace esphome::xdb401 diff --git a/tests/components/xdb401/common.yaml b/tests/components/xdb401/common.yaml new file mode 100644 index 0000000000..feaa46010e --- /dev/null +++ b/tests/components/xdb401/common.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xdb401 + update_interval: 1s + i2c_id: i2c_bus + + temperature: + name: water temperature + + pressure: + name: water pressure diff --git a/tests/components/xdb401/test.esp32-idf.yaml b/tests/components/xdb401/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/xdb401/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/xdb401/test.esp8266-ard.yaml b/tests/components/xdb401/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/xdb401/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 4f62bb7171fb30762c3c941ea66e89024ca4a714 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:04:28 +1000 Subject: [PATCH 271/282] [bmi270] Support Bosch BMI270 IMU (#16202) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bmi270/__init__.py | 10 + esphome/components/bmi270/bmi270.cpp | 209 +++++++++ esphome/components/bmi270/bmi270.h | 108 +++++ esphome/components/bmi270/bmi270_config.h | 483 ++++++++++++++++++++ esphome/components/bmi270/motion.py | 91 ++++ esphome/components/bmi270/sensor.py | 41 ++ esphome/components/const/__init__.py | 4 + tests/components/bmi270/common.yaml | 68 +++ tests/components/bmi270/test.esp32-idf.yaml | 4 + 10 files changed, 1019 insertions(+) create mode 100644 esphome/components/bmi270/__init__.py create mode 100644 esphome/components/bmi270/bmi270.cpp create mode 100644 esphome/components/bmi270/bmi270.h create mode 100644 esphome/components/bmi270/bmi270_config.h create mode 100644 esphome/components/bmi270/motion.py create mode 100644 esphome/components/bmi270/sensor.py create mode 100644 tests/components/bmi270/common.yaml create mode 100644 tests/components/bmi270/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index a64d6f3daf..300ae13cf4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -83,6 +83,7 @@ esphome/components/bme680_bsec/* @trvrnrth esphome/components/bme68x_bsec2/* @kbx81 @neffs esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs esphome/components/bmi160/* @flaviut +esphome/components/bmi270/* @clydebarrow esphome/components/bmp280_base/* @ademuri esphome/components/bmp280_i2c/* @ademuri esphome/components/bmp280_spi/* @ademuri diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py new file mode 100644 index 0000000000..0e67e41a0e --- /dev/null +++ b/esphome/components/bmi270/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_BMI270_ID = "bmi270_id" +# C++ namespace / class +bmi270_ns = cg.esphome_ns.namespace("bmi270") +BMI270Component = bmi270_ns.class_("BMI270Component", MotionComponent, i2c.I2CDevice) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp new file mode 100644 index 0000000000..acb93158d4 --- /dev/null +++ b/esphome/components/bmi270/bmi270.cpp @@ -0,0 +1,209 @@ +#include "bmi270.h" +#include "bmi270_config.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::bmi270 { + +static const char *const TAG = "bmi270"; + +#if defined(USE_ARDUINO) && !defined(USE_ESP32) +static const size_t MAX_I2C_BUFFER_SIZE = 32; +#else +static const size_t MAX_I2C_BUFFER_SIZE = 256; +#endif + +// Configuration blob upload +// The BMI270 requires a firmware config blob to be written to its internal +// memory after every power-on before sensors can be used. + +bool BMI270Component::load_config_file_() { + // 1. Disable advanced power-save so the config port is accessible + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + return false; + delay(1); + + // 2. Prepare config load: write 0x00 to INIT_CTRL to start + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + return false; + + // 3. Burst-write the config in pages + const uint8_t *cfg = BMI270_CONFIG_FILE; + constexpr size_t cfg_len = sizeof(BMI270_CONFIG_FILE); + size_t index = 0; + + while (index != cfg_len) { + // Set the page address in INIT_ADDR registers + uint8_t addr_lsb = (uint8_t) ((index / 2) & 0x0F); + uint8_t addr_msb = (uint8_t) ((index / 2) >> 4); + if (!this->write_byte(BMI270_REG_INIT_ADDR_0, addr_lsb)) + return false; + if (!this->write_byte(BMI270_REG_INIT_ADDR_0 + 1, addr_msb)) + return false; + + // Write a burst of up to the maximum allowed size + size_t burst = clamp_at_most(cfg_len - index, MAX_I2C_BUFFER_SIZE); + if (this->write_register(BMI270_REG_INIT_DATA, cfg + index, burst) != i2c::ERROR_OK) + return false; + + index += burst; + } + + // 4. Signal end of config load + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + return false; + delay(20); // spec: wait ≥20 ms for init to complete + + // 5. Check INTERNAL_STATUS: bit[0:3] should be 0x01 ("initialisation OK") + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) + return false; + if ((status & 0x0F) != 0x01) { + ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); + return false; + } + return true; +} + +// setup() ─ + +void BMI270Component::setup() { + MotionComponent::setup(); + // 1. Verify chip ID + uint8_t chip_id = 0; + if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { + ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); + this->mark_failed(); + return; + } + if (chip_id != BMI270_CHIP_ID_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); + + // 2. Soft-reset via CMD register (0x7E = 0xB6) + if (!this->write_byte(0x7E, 0xB6)) { + this->mark_failed(); + return; + } + delay(20); + + // 4. Upload the configuration blob + if (!load_config_file_()) { + ESP_LOGE(TAG, "Config file upload failed"); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Config blob uploaded ✓"); + + // 5. Configure accelerometer + // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) + uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { + this->mark_failed(); + return; + } + + // 6. Configure gyroscope + // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { + this->mark_failed(); + return; + } + + // 7. Enable accelerometer, gyroscope, and temperature sensor + // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] + if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + this->mark_failed(); + return; + } + delay(5); + + // 8. Re-enable advanced power save (optional; keeps current low between reads) + // Disabled here for simplicity – leave in performance mode + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + this->mark_failed(); + return; + } + + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); +} + +void BMI270Component::dump_config() { + ESP_LOGCONFIG(TAG, "BMI270 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + MotionComponent::dump_config(); +} + +// update() ─ +// Reads all 6 axes + temperature in one block + +bool BMI270Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + // Scale factor: LSB/g depends on range + // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb + static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, + }; + float scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; + data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + + // Gyroscope: registers 0x12–0x17 (6 bytes) + // Scale: full-scale range / 2^15 + static constexpr float GYRO_SCALE[] = { + 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, + }; + static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; + scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h new file mode 100644 index 0000000000..7c5a2db015 --- /dev/null +++ b/esphome/components/bmi270/bmi270.h @@ -0,0 +1,108 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include + +namespace esphome::bmi270 { + +// Register map +static const uint8_t BMI270_REG_CHIP_ID = 0x00; +static const uint8_t BMI270_REG_ERR_REG = 0x02; +static const uint8_t BMI270_REG_STATUS = 0x03; +static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static const uint8_t BMI270_REG_TEMP_0 = 0x22; +static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) + +static constexpr uint8_t REG_READ_LEN = + BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) + +static const uint8_t BMI270_REG_PWR_CONF = 0x7C; +static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static const uint8_t BMI270_REG_INIT_CTRL = 0x59; +static const uint8_t BMI270_REG_INIT_DATA = 0x5E; +static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static const uint8_t BMI270_REG_ACC_CONF = 0x40; +static const uint8_t BMI270_REG_ACC_RANGE = 0x41; +static const uint8_t BMI270_REG_GYR_CONF = 0x42; +static const uint8_t BMI270_REG_GYR_RANGE = 0x43; + +static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Accelerometer range options +enum BMI270AccelRange : uint8_t { + BMI270_ACCEL_RANGE_2G = 0x00, + BMI270_ACCEL_RANGE_4G = 0x01, + BMI270_ACCEL_RANGE_8G = 0x02, + BMI270_ACCEL_RANGE_16G = 0x03, +}; + +// Accelerometer ODR options +enum BMI270AccelODR : uint8_t { + BMI270_ACCEL_ODR_12_5 = 0x05, + BMI270_ACCEL_ODR_25 = 0x06, + BMI270_ACCEL_ODR_50 = 0x07, + BMI270_ACCEL_ODR_100 = 0x08, + BMI270_ACCEL_ODR_200 = 0x09, + BMI270_ACCEL_ODR_400 = 0x0A, + BMI270_ACCEL_ODR_800 = 0x0B, + BMI270_ACCEL_ODR_1600 = 0x0C, +}; + +// Gyroscope range options +enum BMI270GyroRange : uint8_t { + BMI270_GYRO_RANGE_2000 = 0x00, + BMI270_GYRO_RANGE_1000 = 0x01, + BMI270_GYRO_RANGE_500 = 0x02, + BMI270_GYRO_RANGE_250 = 0x03, + BMI270_GYRO_RANGE_125 = 0x04, +}; + +// Gyroscope ODR options +enum BMI270GyroODR : uint8_t { + BMI270_GYRO_ODR_25 = 0x06, + BMI270_GYRO_ODR_50 = 0x07, + BMI270_GYRO_ODR_100 = 0x08, + BMI270_GYRO_ODR_200 = 0x09, + BMI270_GYRO_ODR_400 = 0x0A, + BMI270_GYRO_ODR_800 = 0x0B, + BMI270_GYRO_ODR_1600 = 0x0C, + BMI270_GYRO_ODR_3200 = 0x0D, +}; + +// ---Data class + +// Main component class +class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(BMI270AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool load_config_file_(); + + // Config + BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; + BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; + BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; + BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270_config.h b/esphome/components/bmi270/bmi270_config.h new file mode 100644 index 0000000000..4243f4e157 --- /dev/null +++ b/esphome/components/bmi270/bmi270_config.h @@ -0,0 +1,483 @@ +#pragma once +#include + +namespace esphome::bmi270 { + +/** + BMI270 configuration file (chip ID 0x24, firmware v2.86.1) + Source: Bosch Sensortec BMI270_SensorAPI (BSD-3-Clause) + https://github.com/boschsensortec/BMI270_SensorAPI + +Copyright (c) 2023 Bosch Sensortec GmbH. All rights reserved. + +BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + + This blob MUST be written to the chip's internal INIT_DATA register + after every power cycle, before any sensor data can be read. + --------------------------------------------------------------------------- */ + +static constexpr uint8_t BMI270_CONFIG_FILE[] = { + 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x3d, 0xb1, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x91, 0x03, 0x80, 0x2e, 0xbc, + 0xb0, 0x80, 0x2e, 0xa3, 0x03, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x00, 0xb0, 0x50, 0x30, 0x21, 0x2e, 0x59, 0xf5, + 0x10, 0x30, 0x21, 0x2e, 0x6a, 0xf5, 0x80, 0x2e, 0x3b, 0x03, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x01, 0x00, 0x22, + 0x00, 0x75, 0x00, 0x00, 0x10, 0x00, 0x10, 0xd1, 0x00, 0xb3, 0x43, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0xe0, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x92, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + 0xe0, 0xaa, 0x38, 0x05, 0xe0, 0x90, 0x30, 0xfa, 0x00, 0x96, 0x00, 0x4b, 0x09, 0x11, 0x00, 0x11, 0x00, 0x02, 0x00, + 0x2d, 0x01, 0xd4, 0x7b, 0x3b, 0x01, 0xdb, 0x7a, 0x04, 0x00, 0x3f, 0x7b, 0xcd, 0x6c, 0xc3, 0x04, 0x85, 0x09, 0xc3, + 0x04, 0xec, 0xe6, 0x0c, 0x46, 0x01, 0x00, 0x27, 0x00, 0x19, 0x00, 0x96, 0x00, 0xa0, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0xf0, 0x3c, 0x00, 0x01, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x32, 0x00, 0x05, 0x00, 0xee, + 0x06, 0x04, 0x00, 0xc8, 0x00, 0x00, 0x00, 0x04, 0x00, 0xa8, 0x05, 0xee, 0x06, 0x00, 0x04, 0xbc, 0x02, 0xb3, 0x00, + 0x85, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xb4, 0x00, 0x01, 0x00, 0xb9, 0x00, 0x01, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x80, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0xde, + 0x00, 0xeb, 0x00, 0xda, 0x00, 0x00, 0x0c, 0xff, 0x0f, 0x00, 0x04, 0xc0, 0x00, 0x5b, 0xf5, 0xc9, 0x01, 0x1e, 0xf2, + 0x80, 0x00, 0x3f, 0xff, 0x19, 0xf4, 0x58, 0xf5, 0x66, 0xf5, 0x64, 0xf5, 0xc0, 0xf1, 0xf0, 0x00, 0xe0, 0x00, 0xcd, + 0x01, 0xd3, 0x01, 0xdb, 0x01, 0xff, 0x7f, 0xff, 0x01, 0xe4, 0x00, 0x74, 0xf7, 0xf3, 0x00, 0xfa, 0x00, 0xff, 0x3f, + 0xca, 0x03, 0x6c, 0x38, 0x56, 0xfe, 0x44, 0xfd, 0xbc, 0x02, 0xf9, 0x06, 0x00, 0xfc, 0x12, 0x02, 0xae, 0x01, 0x58, + 0xfa, 0x9a, 0xfd, 0x77, 0x05, 0xbb, 0x02, 0x96, 0x01, 0x95, 0x01, 0x7f, 0x01, 0x82, 0x01, 0x89, 0x01, 0x87, 0x01, + 0x88, 0x01, 0x8a, 0x01, 0x8c, 0x01, 0x8f, 0x01, 0x8d, 0x01, 0x92, 0x01, 0x91, 0x01, 0xdd, 0x00, 0x9f, 0x01, 0x7e, + 0x01, 0xdb, 0x00, 0xb6, 0x01, 0x70, 0x69, 0x26, 0xd3, 0x9c, 0x07, 0x1f, 0x05, 0x9d, 0x00, 0x00, 0x08, 0xbc, 0x05, + 0x37, 0xfa, 0xa2, 0x01, 0xaa, 0x01, 0xa1, 0x01, 0xa8, 0x01, 0xa0, 0x01, 0xa8, 0x05, 0xb4, 0x01, 0xb4, 0x01, 0xce, + 0x00, 0xd0, 0x00, 0xfc, 0x00, 0xc5, 0x01, 0xff, 0xfb, 0xb1, 0x00, 0x00, 0x38, 0x00, 0x30, 0xfd, 0xf5, 0xfc, 0xf5, + 0xcd, 0x01, 0xa0, 0x00, 0x5f, 0xff, 0x00, 0x40, 0xff, 0x00, 0x00, 0x80, 0x6d, 0x0f, 0xeb, 0x00, 0x7f, 0xff, 0xc2, + 0xf5, 0x68, 0xf7, 0xb3, 0xf1, 0x67, 0x0f, 0x5b, 0x0f, 0x61, 0x0f, 0x80, 0x0f, 0x58, 0xf7, 0x5b, 0xf7, 0x83, 0x0f, + 0x86, 0x00, 0x72, 0x0f, 0x85, 0x0f, 0xc6, 0xf1, 0x7f, 0x0f, 0x6c, 0xf7, 0x00, 0xe0, 0x00, 0xff, 0xd1, 0xf5, 0x87, + 0x0f, 0x8a, 0x0f, 0xff, 0x03, 0xf0, 0x3f, 0x8b, 0x00, 0x8e, 0x00, 0x90, 0x00, 0xb9, 0x00, 0x2d, 0xf5, 0xca, 0xf5, + 0xcb, 0x01, 0x20, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x50, 0x98, 0x2e, + 0xd7, 0x0e, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, 0xf0, 0x7f, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x00, + 0x2e, 0x01, 0x80, 0x08, 0xa2, 0xfb, 0x2f, 0x98, 0x2e, 0xba, 0x03, 0x21, 0x2e, 0x19, 0x00, 0x01, 0x2e, 0xee, 0x00, + 0x00, 0xb2, 0x07, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x03, 0x2f, 0x01, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x07, + 0xcc, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x27, 0x2f, 0x05, 0x2e, 0x8a, 0x00, 0x05, 0x52, 0x98, 0x2e, 0xc7, 0xc1, + 0x03, 0x2e, 0xe9, 0x00, 0x40, 0xb2, 0xf0, 0x7f, 0x08, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x00, + 0x30, 0x21, 0x2e, 0xe9, 0x00, 0x98, 0x2e, 0xb4, 0xb1, 0x01, 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x10, 0x2f, 0x05, 0x50, + 0x98, 0x2e, 0x4d, 0xc3, 0x05, 0x50, 0x98, 0x2e, 0x5a, 0xc7, 0x98, 0x2e, 0xf9, 0xb4, 0x98, 0x2e, 0x54, 0xb2, 0x98, + 0x2e, 0x67, 0xb6, 0x98, 0x2e, 0x17, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x2e, 0xef, 0x00, 0x00, 0xb2, + 0x04, 0x2f, 0x98, 0x2e, 0x7a, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xef, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xae, 0x0b, + 0x2f, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x07, 0x2f, 0x05, 0x52, 0x98, 0x2e, 0x8e, 0x0e, 0x00, 0xb2, 0x02, 0x2f, + 0x10, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x01, 0x2e, 0x7d, 0x00, 0x00, 0x90, 0x90, 0x2e, 0xf1, 0x02, 0x01, 0x2e, 0xd7, + 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x2f, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7b, 0x00, + 0x00, 0xb2, 0x12, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x00, 0x90, 0x02, 0x2f, 0x98, 0x2e, 0x1f, 0x0e, 0x09, 0x2d, 0x98, + 0x2e, 0x81, 0x0d, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0x90, 0x02, 0x2f, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, + 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7c, 0x00, 0x00, 0xb2, 0x90, 0x2e, 0x09, 0x03, 0x01, 0x2e, 0x7c, 0x00, 0x01, + 0x31, 0x01, 0x08, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x47, 0xcb, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x81, 0x30, + 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, 0xb2, 0x61, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x98, + 0xbc, 0x98, 0xb8, 0x05, 0xb2, 0x0f, 0x58, 0x23, 0x2f, 0x07, 0x90, 0x09, 0x54, 0x00, 0x30, 0x37, 0x2f, 0x15, 0x41, + 0x04, 0x41, 0xdc, 0xbe, 0x44, 0xbe, 0xdc, 0xba, 0x2c, 0x01, 0x61, 0x00, 0x0f, 0x56, 0x4a, 0x0f, 0x0c, 0x2f, 0xd1, + 0x42, 0x94, 0xb8, 0xc1, 0x42, 0x11, 0x30, 0x05, 0x2e, 0x6a, 0xf7, 0x2c, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x08, 0x22, + 0x98, 0x2e, 0xc3, 0xb7, 0x21, 0x2d, 0x61, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x98, 0x2e, 0xc3, 0xb7, 0x00, 0x30, 0x21, + 0x2e, 0x5a, 0xf5, 0x18, 0x2d, 0xe1, 0x7f, 0x50, 0x30, 0x98, 0x2e, 0xfa, 0x03, 0x0f, 0x52, 0x07, 0x50, 0x50, 0x42, + 0x70, 0x30, 0x0d, 0x54, 0x42, 0x42, 0x7e, 0x82, 0xe2, 0x6f, 0x80, 0xb2, 0x42, 0x42, 0x05, 0x2f, 0x21, 0x2e, 0xd4, + 0x00, 0x10, 0x30, 0x98, 0x2e, 0xc3, 0xb7, 0x03, 0x2d, 0x60, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x01, 0x2e, 0xd4, 0x00, + 0x06, 0x90, 0x18, 0x2f, 0x01, 0x2e, 0x76, 0x00, 0x0b, 0x54, 0x07, 0x52, 0xe0, 0x7f, 0x98, 0x2e, 0x7a, 0xc1, 0xe1, + 0x6f, 0x08, 0x1a, 0x40, 0x30, 0x08, 0x2f, 0x21, 0x2e, 0xd4, 0x00, 0x20, 0x30, 0x98, 0x2e, 0xaf, 0xb7, 0x50, 0x32, + 0x98, 0x2e, 0xfa, 0x03, 0x05, 0x2d, 0x98, 0x2e, 0x38, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x00, 0x30, 0x21, + 0x2e, 0x7c, 0x00, 0x18, 0x2d, 0x01, 0x2e, 0xd4, 0x00, 0x03, 0xaa, 0x01, 0x2f, 0x98, 0x2e, 0x45, 0x0e, 0x01, 0x2e, + 0xd4, 0x00, 0x3f, 0x80, 0x03, 0xa2, 0x01, 0x2f, 0x00, 0x2e, 0x02, 0x2d, 0x98, 0x2e, 0x5b, 0x0e, 0x30, 0x30, 0x98, + 0x2e, 0xce, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x01, 0x2e, 0x77, 0x00, + 0x00, 0xb2, 0x24, 0x2f, 0x98, 0x2e, 0xf5, 0xcb, 0x03, 0x2e, 0xd5, 0x00, 0x11, 0x54, 0x01, 0x0a, 0xbc, 0x84, 0x83, + 0x86, 0x21, 0x2e, 0xc9, 0x01, 0xe0, 0x40, 0x13, 0x52, 0xc4, 0x40, 0x82, 0x40, 0xa8, 0xb9, 0x52, 0x42, 0x43, 0xbe, + 0x53, 0x42, 0x04, 0x0a, 0x50, 0x42, 0xe1, 0x7f, 0xf0, 0x31, 0x41, 0x40, 0xf2, 0x6f, 0x25, 0xbd, 0x08, 0x08, 0x02, + 0x0a, 0xd0, 0x7f, 0x98, 0x2e, 0xa8, 0xcf, 0x06, 0xbc, 0xd1, 0x6f, 0xe2, 0x6f, 0x08, 0x0a, 0x80, 0x42, 0x98, 0x2e, + 0x58, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x21, 0x2e, 0x77, 0x00, 0x21, 0x2e, 0xdd, 0x00, 0x80, 0x2e, 0xf4, + 0x01, 0x1a, 0x24, 0x22, 0x00, 0x80, 0x2e, 0xec, 0x01, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0xfb, 0x6f, 0x01, 0x30, 0x71, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, 0x2f, 0xc0, 0x2e, 0x01, 0x42, 0xf0, 0x5f, 0x80, + 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x01, + 0x34, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x06, 0x32, 0x0f, 0x2e, 0x61, 0xf5, 0xfe, 0x09, 0xc0, 0xb3, 0x04, + 0x2f, 0x17, 0x30, 0x2f, 0x2e, 0xef, 0x00, 0x2d, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, + 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x46, 0x30, 0x0f, 0x2e, 0xa4, 0xf1, 0xbe, 0x09, 0x80, 0xb3, 0x06, 0x2f, 0x0d, + 0x2e, 0xd4, 0x00, 0x84, 0xaf, 0x02, 0x2f, 0x16, 0x30, 0x2d, 0x2e, 0x7b, 0x00, 0x86, 0x30, 0x2d, 0x2e, 0x60, 0xf5, + 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, 0x01, 0x2e, 0x77, 0xf7, 0x09, 0xbc, 0x0f, 0xb8, 0x00, 0xb2, 0x10, + 0x50, 0xfb, 0x7f, 0x10, 0x30, 0x0b, 0x2f, 0x03, 0x2e, 0x8a, 0x00, 0x96, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x05, 0x2f, + 0x03, 0x2e, 0x68, 0xf7, 0x9e, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x07, 0x2f, 0x03, 0x2e, 0x7e, 0x00, 0x41, 0x90, 0x01, + 0x2f, 0x98, 0x2e, 0xdc, 0x03, 0x03, 0x2c, 0x00, 0x30, 0x21, 0x2e, 0x7e, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, + 0x20, 0x50, 0xe0, 0x7f, 0xfb, 0x7f, 0x00, 0x2e, 0x27, 0x50, 0x98, 0x2e, 0x3b, 0xc8, 0x29, 0x50, 0x98, 0x2e, 0xa7, + 0xc8, 0x01, 0x50, 0x98, 0x2e, 0x55, 0xcc, 0xe1, 0x6f, 0x2b, 0x50, 0x98, 0x2e, 0xe0, 0xc9, 0xfb, 0x6f, 0x00, 0x30, + 0xe0, 0x5f, 0x21, 0x2e, 0x7e, 0x00, 0xb8, 0x2e, 0x73, 0x50, 0x01, 0x30, 0x57, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, + 0x2f, 0xb8, 0x2e, 0x21, 0x2e, 0x59, 0xf5, 0x10, 0x30, 0xc0, 0x2e, 0x21, 0x2e, 0x4a, 0xf1, 0x90, 0x50, 0xf7, 0x7f, + 0xe6, 0x7f, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0xa1, 0x7f, 0x90, 0x7f, 0x82, 0x7f, 0x7b, 0x7f, 0x98, 0x2e, 0x35, + 0xb7, 0x00, 0xb2, 0x90, 0x2e, 0x97, 0xb0, 0x03, 0x2e, 0x8f, 0x00, 0x07, 0x2e, 0x91, 0x00, 0x05, 0x2e, 0xb1, 0x00, + 0x3f, 0xba, 0x9f, 0xb8, 0x01, 0x2e, 0xb1, 0x00, 0xa3, 0xbd, 0x4c, 0x0a, 0x05, 0x2e, 0xb1, 0x00, 0x04, 0xbe, 0xbf, + 0xb9, 0xcb, 0x0a, 0x4f, 0xba, 0x22, 0xbd, 0x01, 0x2e, 0xb3, 0x00, 0xdc, 0x0a, 0x2f, 0xb9, 0x03, 0x2e, 0xb8, 0x00, + 0x0a, 0xbe, 0x9a, 0x0a, 0xcf, 0xb9, 0x9b, 0xbc, 0x01, 0x2e, 0x97, 0x00, 0x9f, 0xb8, 0x93, 0x0a, 0x0f, 0xbc, 0x91, + 0x0a, 0x0f, 0xb8, 0x90, 0x0a, 0x25, 0x2e, 0x18, 0x00, 0x05, 0x2e, 0xc1, 0xf5, 0x2e, 0xbd, 0x2e, 0xb9, 0x01, 0x2e, + 0x19, 0x00, 0x31, 0x30, 0x8a, 0x04, 0x00, 0x90, 0x07, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xa2, 0x03, 0x2f, 0x01, + 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x0c, 0x2f, 0x19, 0x50, 0x05, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x05, 0x2e, 0x78, 0x00, + 0x80, 0x90, 0x10, 0x30, 0x01, 0x2f, 0x21, 0x2e, 0x78, 0x00, 0x25, 0x2e, 0xdd, 0x00, 0x98, 0x2e, 0x3e, 0xb7, 0x00, + 0xb2, 0x02, 0x30, 0x01, 0x30, 0x04, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x00, 0x2f, 0x21, 0x30, 0x01, 0x2e, + 0xea, 0x00, 0x08, 0x1a, 0x0e, 0x2f, 0x23, 0x2e, 0xea, 0x00, 0x33, 0x30, 0x1b, 0x50, 0x0b, 0x09, 0x01, 0x40, 0x17, + 0x56, 0x46, 0xbe, 0x4b, 0x08, 0x4c, 0x0a, 0x01, 0x42, 0x0a, 0x80, 0x15, 0x52, 0x01, 0x42, 0x00, 0x2e, 0x01, 0x2e, + 0x18, 0x00, 0x00, 0xb2, 0x1f, 0x2f, 0x03, 0x2e, 0xc0, 0xf5, 0xf0, 0x30, 0x48, 0x08, 0x47, 0xaa, 0x74, 0x30, 0x07, + 0x2e, 0x7a, 0x00, 0x61, 0x22, 0x4b, 0x1a, 0x05, 0x2f, 0x07, 0x2e, 0x66, 0xf5, 0xbf, 0xbd, 0xbf, 0xb9, 0xc0, 0x90, + 0x0b, 0x2f, 0x1d, 0x56, 0x2b, 0x30, 0xd2, 0x42, 0xdb, 0x42, 0x01, 0x04, 0xc2, 0x42, 0x04, 0xbd, 0xfe, 0x80, 0x81, + 0x84, 0x23, 0x2e, 0x7a, 0x00, 0x02, 0x42, 0x02, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x05, 0x2e, 0xd6, 0x00, 0x81, 0x84, + 0x25, 0x2e, 0xd6, 0x00, 0x02, 0x31, 0x25, 0x2e, 0x60, 0xf5, 0x05, 0x2e, 0x8a, 0x00, 0x0b, 0x50, 0x90, 0x08, 0x80, + 0xb2, 0x0b, 0x2f, 0x05, 0x2e, 0xca, 0xf5, 0xf0, 0x3e, 0x90, 0x08, 0x25, 0x2e, 0xca, 0xf5, 0x05, 0x2e, 0x59, 0xf5, + 0xe0, 0x3f, 0x90, 0x08, 0x25, 0x2e, 0x59, 0xf5, 0x90, 0x6f, 0xa1, 0x6f, 0xb3, 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0xe6, + 0x6f, 0xf7, 0x6f, 0x7b, 0x6f, 0x82, 0x6f, 0x70, 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0x90, 0x7f, 0xe5, 0x7f, 0xd4, 0x7f, + 0xc3, 0x7f, 0xb1, 0x7f, 0xa2, 0x7f, 0x87, 0x7f, 0xf6, 0x7f, 0x7b, 0x7f, 0x00, 0x2e, 0x01, 0x2e, 0x60, 0xf5, 0x60, + 0x7f, 0x98, 0x2e, 0x35, 0xb7, 0x02, 0x30, 0x63, 0x6f, 0x15, 0x52, 0x50, 0x7f, 0x62, 0x7f, 0x5a, 0x2c, 0x02, 0x32, + 0x1a, 0x09, 0x00, 0xb3, 0x14, 0x2f, 0x00, 0xb2, 0x03, 0x2f, 0x09, 0x2e, 0x18, 0x00, 0x00, 0x91, 0x0c, 0x2f, 0x43, + 0x7f, 0x98, 0x2e, 0x97, 0xb7, 0x1f, 0x50, 0x02, 0x8a, 0x02, 0x32, 0x04, 0x30, 0x25, 0x2e, 0x64, 0xf5, 0x15, 0x52, + 0x50, 0x6f, 0x43, 0x6f, 0x44, 0x43, 0x25, 0x2e, 0x60, 0xf5, 0xd9, 0x08, 0xc0, 0xb2, 0x36, 0x2f, 0x98, 0x2e, 0x3e, + 0xb7, 0x00, 0xb2, 0x06, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x02, 0x2f, 0x50, 0x6f, 0x00, 0x90, 0x0a, 0x2f, + 0x01, 0x2e, 0x79, 0x00, 0x00, 0x90, 0x19, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x79, 0x00, 0x00, 0x30, 0x98, 0x2e, 0xdc, + 0x03, 0x13, 0x2d, 0x01, 0x2e, 0xc3, 0xf5, 0x0c, 0xbc, 0x0f, 0xb8, 0x12, 0x30, 0x10, 0x04, 0x03, 0xb0, 0x26, 0x25, + 0x21, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x10, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x02, 0x30, 0x60, 0x7f, 0x25, + 0x2e, 0x79, 0x00, 0x60, 0x6f, 0x00, 0x90, 0x05, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0xea, 0x00, 0x15, 0x50, 0x21, 0x2e, + 0x64, 0xf5, 0x15, 0x52, 0x23, 0x2e, 0x60, 0xf5, 0x02, 0x32, 0x50, 0x6f, 0x00, 0x90, 0x02, 0x2f, 0x03, 0x30, 0x27, + 0x2e, 0x78, 0x00, 0x07, 0x2e, 0x60, 0xf5, 0x1a, 0x09, 0x00, 0x91, 0xa3, 0x2f, 0x19, 0x09, 0x00, 0x91, 0xa0, 0x2f, + 0x90, 0x6f, 0xa2, 0x6f, 0xb1, 0x6f, 0xc3, 0x6f, 0xd4, 0x6f, 0xe5, 0x6f, 0x7b, 0x6f, 0xf6, 0x6f, 0x87, 0x6f, 0x40, + 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x26, 0x30, 0x0f, 0x2e, 0x61, 0xf5, 0x2f, 0x2e, 0x7c, 0x00, + 0x0f, 0x2e, 0x7c, 0x00, 0xbe, 0x09, 0xa2, 0x7f, 0x80, 0x7f, 0x80, 0xb3, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0x91, + 0x7f, 0x7b, 0x7f, 0x0b, 0x2f, 0x23, 0x50, 0x1a, 0x25, 0x12, 0x40, 0x42, 0x7f, 0x74, 0x82, 0x12, 0x40, 0x52, 0x7f, + 0x00, 0x2e, 0x00, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0x6a, 0xd6, 0x81, 0x30, 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, + 0xb2, 0x42, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0x89, 0x00, 0x97, 0xbc, 0x06, 0xbc, 0x9f, 0xb8, 0x0f, 0xb8, + 0x00, 0x90, 0x23, 0x2e, 0xd8, 0x00, 0x10, 0x30, 0x01, 0x30, 0x2a, 0x2f, 0x03, 0x2e, 0xd4, 0x00, 0x44, 0xb2, 0x05, + 0x2f, 0x47, 0xb2, 0x00, 0x30, 0x2d, 0x2f, 0x21, 0x2e, 0x7c, 0x00, 0x2b, 0x2d, 0x03, 0x2e, 0xfd, 0xf5, 0x9e, 0xbc, + 0x9f, 0xb8, 0x40, 0x90, 0x14, 0x2f, 0x03, 0x2e, 0xfc, 0xf5, 0x99, 0xbc, 0x9f, 0xb8, 0x40, 0x90, 0x0e, 0x2f, 0x03, + 0x2e, 0x49, 0xf1, 0x25, 0x54, 0x4a, 0x08, 0x40, 0x90, 0x08, 0x2f, 0x98, 0x2e, 0x35, 0xb7, 0x00, 0xb2, 0x10, 0x30, + 0x03, 0x2f, 0x50, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x10, 0x2d, 0x98, 0x2e, 0xaf, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7c, + 0x00, 0x0a, 0x2d, 0x05, 0x2e, 0x69, 0xf7, 0x2d, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x01, 0x2f, 0x21, 0x2e, 0x7d, 0x00, + 0x23, 0x2e, 0x7c, 0x00, 0xe0, 0x31, 0x21, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0x80, 0x6f, 0xa2, 0x6f, 0xb3, + 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0x7b, 0x6f, 0x91, 0x6f, 0x40, 0x5f, 0xc8, 0x2e, 0x60, 0x51, 0x0a, 0x25, 0x36, 0x88, + 0xf4, 0x7f, 0xeb, 0x7f, 0x00, 0x32, 0x31, 0x52, 0x32, 0x30, 0x13, 0x30, 0x98, 0x2e, 0x15, 0xcb, 0x0a, 0x25, 0x33, + 0x84, 0xd2, 0x7f, 0x43, 0x30, 0x05, 0x50, 0x2d, 0x52, 0x98, 0x2e, 0x95, 0xc1, 0xd2, 0x6f, 0x27, 0x52, 0x98, 0x2e, + 0xd7, 0xc7, 0x2a, 0x25, 0xb0, 0x86, 0xc0, 0x7f, 0xd3, 0x7f, 0xaf, 0x84, 0x29, 0x50, 0xf1, 0x6f, 0x98, 0x2e, 0x4d, + 0xc8, 0x2a, 0x25, 0xae, 0x8a, 0xaa, 0x88, 0xf2, 0x6e, 0x2b, 0x50, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x7f, 0x98, 0x2e, + 0xb6, 0xc8, 0xe0, 0x6e, 0x00, 0xb2, 0x32, 0x2f, 0x33, 0x54, 0x83, 0x86, 0xf1, 0x6f, 0xc3, 0x7f, 0x04, 0x30, 0x30, + 0x30, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0xe3, 0x30, 0xc5, 0x6f, 0x56, 0x40, 0x45, 0x41, 0x28, 0x08, 0x03, 0x14, + 0x0e, 0xb4, 0x08, 0xbc, 0x82, 0x40, 0x10, 0x0a, 0x2f, 0x54, 0x26, 0x05, 0x91, 0x7f, 0x44, 0x28, 0xa3, 0x7f, 0x98, + 0x2e, 0xd9, 0xc0, 0x08, 0xb9, 0x33, 0x30, 0x53, 0x09, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x6f, 0x83, 0x17, 0x47, 0x40, + 0x6c, 0x15, 0xb2, 0x6f, 0xbe, 0x09, 0x75, 0x0b, 0x90, 0x42, 0x45, 0x42, 0x51, 0x0e, 0x32, 0xbc, 0x02, 0x89, 0xa1, + 0x6f, 0x7e, 0x86, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0x04, 0x30, 0x91, 0x6f, 0xd6, 0x2f, 0xeb, 0x6f, 0xa0, 0x5e, + 0xb8, 0x2e, 0x03, 0x2e, 0x97, 0x00, 0x1b, 0xbc, 0x60, 0x50, 0x9f, 0xbc, 0x0c, 0xb8, 0xf0, 0x7f, 0x40, 0xb2, 0xeb, + 0x7f, 0x2b, 0x2f, 0x03, 0x2e, 0x7f, 0x00, 0x41, 0x40, 0x01, 0x2e, 0xc8, 0x00, 0x01, 0x1a, 0x11, 0x2f, 0x37, 0x58, + 0x23, 0x2e, 0xc8, 0x00, 0x10, 0x41, 0xa0, 0x7f, 0x38, 0x81, 0x01, 0x41, 0xd0, 0x7f, 0xb1, 0x7f, 0x98, 0x2e, 0x64, + 0xcf, 0xd0, 0x6f, 0x07, 0x80, 0xa1, 0x6f, 0x11, 0x42, 0x00, 0x2e, 0xb1, 0x6f, 0x01, 0x42, 0x11, 0x30, 0x01, 0x2e, + 0xfc, 0x00, 0x00, 0xa8, 0x03, 0x30, 0xcb, 0x22, 0x4a, 0x25, 0x01, 0x2e, 0x7f, 0x00, 0x3c, 0x89, 0x35, 0x52, 0x05, + 0x54, 0x98, 0x2e, 0xc4, 0xce, 0xc1, 0x6f, 0xf0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x04, 0x2d, 0x01, 0x30, 0xf0, 0x6f, + 0x98, 0x2e, 0x95, 0xcf, 0xeb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, 0x03, 0x2e, 0xb3, 0x00, 0x02, 0x32, 0xf0, 0x30, 0x03, + 0x31, 0x30, 0x50, 0x8a, 0x08, 0x08, 0x08, 0xcb, 0x08, 0xe0, 0x7f, 0x80, 0xb2, 0xf3, 0x7f, 0xdb, 0x7f, 0x25, 0x2f, + 0x03, 0x2e, 0xca, 0x00, 0x41, 0x90, 0x04, 0x2f, 0x01, 0x30, 0x23, 0x2e, 0xca, 0x00, 0x98, 0x2e, 0x3f, 0x03, 0xc0, + 0xb2, 0x05, 0x2f, 0x03, 0x2e, 0xda, 0x00, 0x00, 0x30, 0x41, 0x04, 0x23, 0x2e, 0xda, 0x00, 0x98, 0x2e, 0x92, 0xb2, + 0x10, 0x25, 0xf0, 0x6f, 0x00, 0xb2, 0x05, 0x2f, 0x01, 0x2e, 0xda, 0x00, 0x02, 0x30, 0x10, 0x04, 0x21, 0x2e, 0xda, + 0x00, 0x40, 0xb2, 0x01, 0x2f, 0x23, 0x2e, 0xc8, 0x01, 0xdb, 0x6f, 0xe0, 0x6f, 0xd0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, + 0x01, 0x30, 0xe0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x11, 0x30, 0x23, 0x2e, 0xca, 0x00, 0xdb, 0x6f, 0xd0, 0x5f, 0xb8, + 0x2e, 0xd0, 0x50, 0x0a, 0x25, 0x33, 0x84, 0x55, 0x50, 0xd2, 0x7f, 0xe2, 0x7f, 0x03, 0x8c, 0xc0, 0x7f, 0xbb, 0x7f, + 0x00, 0x30, 0x05, 0x5a, 0x39, 0x54, 0x51, 0x41, 0xa5, 0x7f, 0x96, 0x7f, 0x80, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0x05, + 0x30, 0xf5, 0x7f, 0x20, 0x25, 0x91, 0x6f, 0x3b, 0x58, 0x3d, 0x5c, 0x3b, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xc1, 0x6f, + 0xd5, 0x6f, 0x52, 0x40, 0x50, 0x43, 0xc1, 0x7f, 0xd5, 0x7f, 0x10, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, + 0x2e, 0x74, 0xc0, 0x86, 0x6f, 0x30, 0x28, 0x92, 0x6f, 0x82, 0x8c, 0xa5, 0x6f, 0x6f, 0x52, 0x69, 0x0e, 0x39, 0x54, + 0xdb, 0x2f, 0x19, 0xa0, 0x15, 0x30, 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x81, 0x01, 0x0a, 0x2d, 0x01, 0x2e, 0x81, + 0x01, 0x05, 0x28, 0x42, 0x36, 0x21, 0x2e, 0x81, 0x01, 0x02, 0x0e, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0x12, 0x30, 0x01, 0x40, 0x98, 0x2e, 0xfe, 0xc9, 0x51, 0x6f, 0x0b, 0x5c, 0x8e, 0x0e, 0x3b, 0x6f, 0x57, 0x58, 0x02, + 0x30, 0x21, 0x2e, 0x95, 0x01, 0x45, 0x6f, 0x2a, 0x8d, 0xd2, 0x7f, 0xcb, 0x7f, 0x13, 0x2f, 0x02, 0x30, 0x3f, 0x50, + 0xd2, 0x7f, 0xa8, 0x0e, 0x0e, 0x2f, 0xc0, 0x6f, 0x53, 0x54, 0x02, 0x00, 0x51, 0x54, 0x42, 0x0e, 0x10, 0x30, 0x59, + 0x52, 0x02, 0x30, 0x01, 0x2f, 0x00, 0x2e, 0x03, 0x2d, 0x50, 0x42, 0x42, 0x42, 0x12, 0x30, 0xd2, 0x7f, 0x80, 0xb2, + 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x80, 0x01, 0x12, 0x2d, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x80, 0x05, 0x2e, 0x80, + 0x01, 0x11, 0x30, 0x91, 0x28, 0x00, 0x40, 0x25, 0x2e, 0x80, 0x01, 0x10, 0x0e, 0x05, 0x2f, 0x01, 0x2e, 0x7f, 0x01, + 0x01, 0x90, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x00, 0x2e, 0xa0, 0x41, 0x01, 0x90, 0xa6, 0x7f, 0x90, 0x2e, 0xe3, + 0xb4, 0x01, 0x2e, 0x95, 0x01, 0x00, 0xa8, 0x90, 0x2e, 0xe3, 0xb4, 0x5b, 0x54, 0x95, 0x80, 0x82, 0x40, 0x80, 0xb2, + 0x02, 0x40, 0x2d, 0x8c, 0x3f, 0x52, 0x96, 0x7f, 0x90, 0x2e, 0xc2, 0xb3, 0x29, 0x0e, 0x76, 0x2f, 0x01, 0x2e, 0xc9, + 0x00, 0x00, 0x40, 0x81, 0x28, 0x45, 0x52, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, 0x5d, 0x54, 0x80, 0x7f, 0x00, 0x2e, + 0xa1, 0x40, 0x72, 0x7f, 0x82, 0x80, 0x82, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, + 0xc0, 0x62, 0x6f, 0x05, 0x30, 0x87, 0x40, 0xc0, 0x91, 0x04, 0x30, 0x05, 0x2f, 0x05, 0x2e, 0x83, 0x01, 0x80, 0xb2, + 0x14, 0x30, 0x00, 0x2f, 0x04, 0x30, 0x05, 0x2e, 0xc9, 0x00, 0x73, 0x6f, 0x81, 0x40, 0xe2, 0x40, 0x69, 0x04, 0x11, + 0x0f, 0xe1, 0x40, 0x16, 0x30, 0xfe, 0x29, 0xcb, 0x40, 0x02, 0x2f, 0x83, 0x6f, 0x83, 0x0f, 0x22, 0x2f, 0x47, 0x56, + 0x13, 0x0f, 0x12, 0x30, 0x77, 0x2f, 0x49, 0x54, 0x42, 0x0e, 0x12, 0x30, 0x73, 0x2f, 0x00, 0x91, 0x0a, 0x2f, 0x01, + 0x2e, 0x8b, 0x01, 0x19, 0xa8, 0x02, 0x30, 0x6c, 0x2f, 0x63, 0x50, 0x00, 0x2e, 0x17, 0x42, 0x05, 0x42, 0x68, 0x2c, + 0x12, 0x30, 0x0b, 0x25, 0x08, 0x0f, 0x50, 0x30, 0x02, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x03, 0x2d, 0x40, 0x30, 0x21, + 0x2e, 0x83, 0x01, 0x2b, 0x2e, 0x85, 0x01, 0x5a, 0x2c, 0x12, 0x30, 0x00, 0x91, 0x2b, 0x25, 0x04, 0x2f, 0x63, 0x50, + 0x02, 0x30, 0x17, 0x42, 0x17, 0x2c, 0x02, 0x42, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, 0xc0, 0x05, + 0x2e, 0xc9, 0x00, 0x81, 0x84, 0x5b, 0x30, 0x82, 0x40, 0x37, 0x2e, 0x83, 0x01, 0x02, 0x0e, 0x07, 0x2f, 0x5f, 0x52, + 0x40, 0x30, 0x62, 0x40, 0x41, 0x40, 0x91, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x05, 0x30, 0x2b, 0x2e, 0x85, + 0x01, 0x12, 0x30, 0x36, 0x2c, 0x16, 0x30, 0x15, 0x25, 0x81, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, + 0x74, 0xc0, 0x19, 0xa2, 0x16, 0x30, 0x15, 0x2f, 0x05, 0x2e, 0x97, 0x01, 0x80, 0x6f, 0x82, 0x0e, 0x05, 0x2f, 0x01, + 0x2e, 0x86, 0x01, 0x06, 0x28, 0x21, 0x2e, 0x86, 0x01, 0x0b, 0x2d, 0x03, 0x2e, 0x87, 0x01, 0x5f, 0x54, 0x4e, 0x28, + 0x91, 0x42, 0x00, 0x2e, 0x82, 0x40, 0x90, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x88, 0x01, 0x02, 0x30, 0x13, 0x2c, 0x05, + 0x30, 0xc0, 0x6f, 0x08, 0x1c, 0xa8, 0x0f, 0x16, 0x30, 0x05, 0x30, 0x5b, 0x50, 0x09, 0x2f, 0x02, 0x80, 0x2d, 0x2e, + 0x82, 0x01, 0x05, 0x42, 0x05, 0x80, 0x00, 0x2e, 0x02, 0x42, 0x3e, 0x80, 0x00, 0x2e, 0x06, 0x42, 0x02, 0x30, 0x90, + 0x6f, 0x3e, 0x88, 0x01, 0x40, 0x04, 0x41, 0x4c, 0x28, 0x01, 0x42, 0x07, 0x80, 0x10, 0x25, 0x24, 0x40, 0x00, 0x40, + 0x00, 0xa8, 0xf5, 0x22, 0x23, 0x29, 0x44, 0x42, 0x7a, 0x82, 0x7e, 0x88, 0x43, 0x40, 0x04, 0x41, 0x00, 0xab, 0xf5, + 0x23, 0xdf, 0x28, 0x43, 0x42, 0xd9, 0xa0, 0x14, 0x2f, 0x00, 0x90, 0x02, 0x2f, 0xd2, 0x6f, 0x81, 0xb2, 0x05, 0x2f, + 0x63, 0x54, 0x06, 0x28, 0x90, 0x42, 0x85, 0x42, 0x09, 0x2c, 0x02, 0x30, 0x5b, 0x50, 0x03, 0x80, 0x29, 0x2e, 0x7e, + 0x01, 0x2b, 0x2e, 0x82, 0x01, 0x05, 0x42, 0x12, 0x30, 0x2b, 0x2e, 0x83, 0x01, 0x45, 0x82, 0x00, 0x2e, 0x40, 0x40, + 0x7a, 0x82, 0x02, 0xa0, 0x08, 0x2f, 0x63, 0x50, 0x3b, 0x30, 0x15, 0x42, 0x05, 0x42, 0x37, 0x80, 0x37, 0x2e, 0x7e, + 0x01, 0x05, 0x42, 0x12, 0x30, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x8c, 0x40, 0x40, 0x84, 0x41, 0x7a, 0x8c, 0x04, 0x0f, + 0x03, 0x2f, 0x01, 0x2e, 0x8b, 0x01, 0x19, 0xa4, 0x04, 0x2f, 0x2b, 0x2e, 0x82, 0x01, 0x98, 0x2e, 0xf3, 0x03, 0x12, + 0x30, 0x81, 0x90, 0x61, 0x52, 0x08, 0x2f, 0x65, 0x42, 0x65, 0x42, 0x43, 0x80, 0x39, 0x84, 0x82, 0x88, 0x05, 0x42, + 0x45, 0x42, 0x85, 0x42, 0x05, 0x43, 0x00, 0x2e, 0x80, 0x41, 0x00, 0x90, 0x90, 0x2e, 0xe1, 0xb4, 0x65, 0x54, 0xc1, + 0x6f, 0x80, 0x40, 0x00, 0xb2, 0x43, 0x58, 0x69, 0x50, 0x44, 0x2f, 0x55, 0x5c, 0xb7, 0x87, 0x8c, 0x0f, 0x0d, 0x2e, + 0x96, 0x01, 0xc4, 0x40, 0x36, 0x2f, 0x41, 0x56, 0x8b, 0x0e, 0x2a, 0x2f, 0x0b, 0x52, 0xa1, 0x0e, 0x0a, 0x2f, 0x05, + 0x2e, 0x8f, 0x01, 0x14, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x4b, 0x54, 0x02, 0x0f, 0x69, 0x50, 0x05, 0x30, 0x65, 0x54, + 0x15, 0x2f, 0x03, 0x2e, 0x8e, 0x01, 0x4d, 0x5c, 0x8e, 0x0f, 0x3a, 0x2f, 0x05, 0x2e, 0x8f, 0x01, 0x98, 0x2e, 0xfe, + 0xc9, 0x4f, 0x54, 0x82, 0x0f, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x30, 0x2f, 0x6d, 0x52, 0x15, 0x30, 0x42, 0x8c, + 0x45, 0x42, 0x04, 0x30, 0x2b, 0x2c, 0x84, 0x43, 0x6b, 0x52, 0x42, 0x8c, 0x00, 0x2e, 0x85, 0x43, 0x15, 0x30, 0x24, + 0x2c, 0x45, 0x42, 0x8e, 0x0f, 0x20, 0x2f, 0x0d, 0x2e, 0x8e, 0x01, 0xb1, 0x0e, 0x1c, 0x2f, 0x23, 0x2e, 0x8e, 0x01, + 0x1a, 0x2d, 0x0e, 0x0e, 0x17, 0x2f, 0xa1, 0x0f, 0x15, 0x2f, 0x23, 0x2e, 0x8d, 0x01, 0x13, 0x2d, 0x98, 0x2e, 0x74, + 0xc0, 0x43, 0x54, 0xc2, 0x0e, 0x0a, 0x2f, 0x65, 0x50, 0x04, 0x80, 0x0b, 0x30, 0x06, 0x82, 0x0b, 0x42, 0x79, 0x80, + 0x41, 0x40, 0x12, 0x30, 0x25, 0x2e, 0x8c, 0x01, 0x01, 0x42, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x84, 0x82, 0x43, + 0x84, 0xbe, 0x8c, 0x84, 0x40, 0x86, 0x41, 0x26, 0x29, 0x94, 0x42, 0xbe, 0x8e, 0xd5, 0x7f, 0x19, 0xa1, 0x43, 0x40, + 0x0b, 0x2e, 0x8c, 0x01, 0x84, 0x40, 0xc7, 0x41, 0x5d, 0x29, 0x27, 0x29, 0x45, 0x42, 0x84, 0x42, 0xc2, 0x7f, 0x01, + 0x2f, 0xc0, 0xb3, 0x1d, 0x2f, 0x05, 0x2e, 0x94, 0x01, 0x99, 0xa0, 0x01, 0x2f, 0x80, 0xb3, 0x13, 0x2f, 0x80, 0xb3, + 0x18, 0x2f, 0xc0, 0xb3, 0x16, 0x2f, 0x12, 0x40, 0x01, 0x40, 0x92, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x92, 0x6f, 0x10, + 0x0f, 0x20, 0x30, 0x03, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x0a, 0x2d, 0x21, 0x2e, 0x7e, 0x01, 0x07, 0x2d, + 0x20, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x03, 0x2d, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0xc2, 0x6f, 0x01, 0x2e, 0xc9, + 0x00, 0xbc, 0x84, 0x02, 0x80, 0x82, 0x40, 0x00, 0x40, 0x90, 0x0e, 0xd5, 0x6f, 0x02, 0x2f, 0x15, 0x30, 0x98, 0x2e, + 0xf3, 0x03, 0x41, 0x91, 0x05, 0x30, 0x07, 0x2f, 0x67, 0x50, 0x3d, 0x80, 0x2b, 0x2e, 0x8f, 0x01, 0x05, 0x42, 0x04, + 0x80, 0x00, 0x2e, 0x05, 0x42, 0x02, 0x2c, 0x00, 0x30, 0x00, 0x30, 0xa2, 0x6f, 0x98, 0x8a, 0x86, 0x40, 0x80, 0xa7, + 0x05, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0xc0, 0x30, 0x21, 0x2e, 0x95, 0x01, 0x06, 0x25, 0x1a, 0x25, 0xe2, 0x6f, 0x76, + 0x82, 0x96, 0x40, 0x56, 0x43, 0x51, 0x0e, 0xfb, 0x2f, 0xbb, 0x6f, 0x30, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xb8, 0x00, + 0x01, 0x31, 0x41, 0x08, 0x40, 0xb2, 0x20, 0x50, 0xf2, 0x30, 0x02, 0x08, 0xfb, 0x7f, 0x01, 0x30, 0x10, 0x2f, 0x05, + 0x2e, 0xcc, 0x00, 0x81, 0x90, 0xe0, 0x7f, 0x03, 0x2f, 0x23, 0x2e, 0xcc, 0x00, 0x98, 0x2e, 0x55, 0xb6, 0x98, 0x2e, + 0x1d, 0xb5, 0x10, 0x25, 0xfb, 0x6f, 0xe0, 0x6f, 0xe0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, 0x98, 0x2e, 0x95, 0xcf, 0x10, + 0x30, 0x21, 0x2e, 0xcc, 0x00, 0xfb, 0x6f, 0xe0, 0x5f, 0xb8, 0x2e, 0x00, 0x51, 0x05, 0x58, 0xeb, 0x7f, 0x2a, 0x25, + 0x89, 0x52, 0x6f, 0x5a, 0x89, 0x50, 0x13, 0x41, 0x06, 0x40, 0xb3, 0x01, 0x16, 0x42, 0xcb, 0x16, 0x06, 0x40, 0xf3, + 0x02, 0x13, 0x42, 0x65, 0x0e, 0xf5, 0x2f, 0x05, 0x40, 0x14, 0x30, 0x2c, 0x29, 0x04, 0x42, 0x08, 0xa1, 0x00, 0x30, + 0x90, 0x2e, 0x52, 0xb6, 0xb3, 0x88, 0xb0, 0x8a, 0xb6, 0x84, 0xa4, 0x7f, 0xc4, 0x7f, 0xb5, 0x7f, 0xd5, 0x7f, 0x92, + 0x7f, 0x73, 0x30, 0x04, 0x30, 0x55, 0x40, 0x42, 0x40, 0x8a, 0x17, 0xf3, 0x08, 0x6b, 0x01, 0x90, 0x02, 0x53, 0xb8, + 0x4b, 0x82, 0xad, 0xbe, 0x71, 0x7f, 0x45, 0x0a, 0x09, 0x54, 0x84, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0xa3, 0x6f, 0x7b, + 0x54, 0xd0, 0x42, 0xa3, 0x7f, 0xf2, 0x7f, 0x60, 0x7f, 0x20, 0x25, 0x71, 0x6f, 0x75, 0x5a, 0x77, 0x58, 0x79, 0x5c, + 0x75, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xb1, 0x6f, 0x62, 0x6f, 0x50, 0x42, 0xb1, 0x7f, 0xb3, 0x30, 0x10, 0x25, 0x98, + 0x2e, 0x0f, 0xca, 0x84, 0x6f, 0x20, 0x29, 0x71, 0x6f, 0x92, 0x6f, 0xa5, 0x6f, 0x76, 0x82, 0x6a, 0x0e, 0x73, 0x30, + 0x00, 0x30, 0xd0, 0x2f, 0xd2, 0x6f, 0xd1, 0x7f, 0xb4, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x02, + 0x0a, 0xc2, 0x6f, 0xc0, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x42, 0x0a, 0xc0, 0x6f, 0x08, 0x17, + 0x41, 0x18, 0x89, 0x16, 0xe1, 0x18, 0xd0, 0x18, 0xa1, 0x7f, 0x27, 0x25, 0x16, 0x25, 0x98, 0x2e, 0x79, 0xc0, 0x8b, + 0x54, 0x90, 0x7f, 0xb3, 0x30, 0x82, 0x40, 0x80, 0x90, 0x0d, 0x2f, 0x7d, 0x52, 0x92, 0x6f, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0e, 0x06, 0x2f, 0x8b, 0x50, 0x14, 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x14, 0x42, 0x12, 0x42, 0x01, + 0x42, 0x00, 0x2e, 0x31, 0x6f, 0x98, 0x2e, 0x74, 0xc0, 0x41, 0x6f, 0x80, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x82, 0x6f, + 0x10, 0x04, 0x43, 0x52, 0x01, 0x0f, 0x05, 0x2e, 0xcb, 0x00, 0x00, 0x30, 0x04, 0x30, 0x21, 0x2f, 0x51, 0x6f, 0x43, + 0x58, 0x8c, 0x0e, 0x04, 0x30, 0x1c, 0x2f, 0x85, 0x88, 0x41, 0x6f, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, 0x16, 0x2f, + 0x84, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x0f, 0x2f, 0x82, 0x88, 0x31, 0x6f, 0x04, + 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x08, 0x2f, 0x83, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, + 0x02, 0x2f, 0x21, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x00, 0x91, 0x14, 0x2f, 0x03, 0x2e, 0xa1, 0x01, 0x41, 0x90, 0x0e, + 0x2f, 0x03, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x4c, 0x28, 0x23, 0x2e, 0xad, 0x01, 0x46, 0xa0, 0x06, 0x2f, 0x81, 0x84, + 0x8d, 0x52, 0x48, 0x82, 0x82, 0x40, 0x21, 0x2e, 0xa1, 0x01, 0x42, 0x42, 0x5c, 0x2c, 0x02, 0x30, 0x05, 0x2e, 0xaa, + 0x01, 0x80, 0xb2, 0x02, 0x30, 0x55, 0x2f, 0x03, 0x2e, 0xa9, 0x01, 0x92, 0x6f, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0f, 0x00, 0x30, 0x02, 0x30, 0x4a, 0x2f, 0xa2, 0x6f, 0x87, 0x52, 0x91, 0x00, 0x85, 0x52, 0x51, + 0x0e, 0x02, 0x2f, 0x00, 0x2e, 0x43, 0x2c, 0x02, 0x30, 0xc2, 0x6f, 0x7f, 0x52, 0x91, 0x0e, 0x02, 0x30, 0x3c, 0x2f, + 0x51, 0x6f, 0x81, 0x54, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0xb3, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x32, + 0x6f, 0xc0, 0x7f, 0xb3, 0x30, 0x12, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x42, 0x6f, 0xb0, 0x7f, 0xb3, 0x30, 0x12, 0x25, + 0x98, 0x2e, 0x0f, 0xca, 0xb2, 0x6f, 0x90, 0x28, 0x83, 0x52, 0x98, 0x2e, 0xfe, 0xc9, 0xc2, 0x6f, 0x90, 0x0f, 0x00, + 0x30, 0x02, 0x30, 0x1d, 0x2f, 0x05, 0x2e, 0xa1, 0x01, 0x80, 0xb2, 0x12, 0x30, 0x0f, 0x2f, 0x42, 0x6f, 0x03, 0x2e, + 0xab, 0x01, 0x91, 0x0e, 0x02, 0x30, 0x12, 0x2f, 0x52, 0x6f, 0x03, 0x2e, 0xac, 0x01, 0x91, 0x0f, 0x02, 0x30, 0x0c, + 0x2f, 0x21, 0x2e, 0xaa, 0x01, 0x0a, 0x2c, 0x12, 0x30, 0x03, 0x2e, 0xcb, 0x00, 0x8d, 0x58, 0x08, 0x89, 0x41, 0x40, + 0x11, 0x43, 0x00, 0x43, 0x25, 0x2e, 0xa1, 0x01, 0xd4, 0x6f, 0x8f, 0x52, 0x00, 0x43, 0x3a, 0x89, 0x00, 0x2e, 0x10, + 0x43, 0x10, 0x43, 0x61, 0x0e, 0xfb, 0x2f, 0x03, 0x2e, 0xa0, 0x01, 0x11, 0x1a, 0x02, 0x2f, 0x02, 0x25, 0x21, 0x2e, + 0xa0, 0x01, 0xeb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x91, 0x52, 0x10, 0x30, 0x02, 0x30, 0x95, 0x56, 0x52, 0x42, 0x4b, + 0x0e, 0xfc, 0x2f, 0x8d, 0x54, 0x88, 0x82, 0x93, 0x56, 0x80, 0x42, 0x53, 0x42, 0x40, 0x42, 0x42, 0x86, 0x83, 0x54, + 0xc0, 0x2e, 0xc2, 0x42, 0x00, 0x2e, 0xa3, 0x52, 0x00, 0x51, 0x52, 0x40, 0x47, 0x40, 0x1a, 0x25, 0x01, 0x2e, 0x97, + 0x00, 0x8f, 0xbe, 0x72, 0x86, 0xfb, 0x7f, 0x0b, 0x30, 0x7c, 0xbf, 0xa5, 0x50, 0x10, 0x08, 0xdf, 0xba, 0x70, 0x88, + 0xf8, 0xbf, 0xcb, 0x42, 0xd3, 0x7f, 0x6c, 0xbb, 0xfc, 0xbb, 0xc5, 0x0a, 0x90, 0x7f, 0x1b, 0x7f, 0x0b, 0x43, 0xc0, + 0xb2, 0xe5, 0x7f, 0xb7, 0x7f, 0xa6, 0x7f, 0xc4, 0x7f, 0x90, 0x2e, 0x1c, 0xb7, 0x07, 0x2e, 0xd2, 0x00, 0xc0, 0xb2, + 0x0b, 0x2f, 0x97, 0x52, 0x01, 0x2e, 0xcd, 0x00, 0x82, 0x7f, 0x98, 0x2e, 0xbb, 0xcc, 0x0b, 0x30, 0x37, 0x2e, 0xd2, + 0x00, 0x82, 0x6f, 0x90, 0x6f, 0x1a, 0x25, 0x00, 0xb2, 0x8b, 0x7f, 0x14, 0x2f, 0xa6, 0xbd, 0x25, 0xbd, 0xb6, 0xb9, + 0x2f, 0xb9, 0x80, 0xb2, 0xd4, 0xb0, 0x0c, 0x2f, 0x99, 0x54, 0x9b, 0x56, 0x0b, 0x30, 0x0b, 0x2e, 0xb1, 0x00, 0xa1, + 0x58, 0x9b, 0x42, 0xdb, 0x42, 0x6c, 0x09, 0x2b, 0x2e, 0xb1, 0x00, 0x8b, 0x42, 0xcb, 0x42, 0x86, 0x7f, 0x73, 0x84, + 0xa7, 0x56, 0xc3, 0x08, 0x39, 0x52, 0x05, 0x50, 0x72, 0x7f, 0x63, 0x7f, 0x98, 0x2e, 0xc2, 0xc0, 0xe1, 0x6f, 0x62, + 0x6f, 0xd1, 0x0a, 0x01, 0x2e, 0xcd, 0x00, 0xd5, 0x6f, 0xc4, 0x6f, 0x72, 0x6f, 0x97, 0x52, 0x9d, 0x5c, 0x98, 0x2e, + 0x06, 0xcd, 0x23, 0x6f, 0x90, 0x6f, 0x99, 0x52, 0xc0, 0xb2, 0x04, 0xbd, 0x54, 0x40, 0xaf, 0xb9, 0x45, 0x40, 0xe1, + 0x7f, 0x02, 0x30, 0x06, 0x2f, 0xc0, 0xb2, 0x02, 0x30, 0x03, 0x2f, 0x9b, 0x5c, 0x12, 0x30, 0x94, 0x43, 0x85, 0x43, + 0x03, 0xbf, 0x6f, 0xbb, 0x80, 0xb3, 0x20, 0x2f, 0x06, 0x6f, 0x26, 0x01, 0x16, 0x6f, 0x6e, 0x03, 0x45, 0x42, 0xc0, + 0x90, 0x29, 0x2e, 0xce, 0x00, 0x9b, 0x52, 0x14, 0x2f, 0x9b, 0x5c, 0x00, 0x2e, 0x93, 0x41, 0x86, 0x41, 0xe3, 0x04, + 0xae, 0x07, 0x80, 0xab, 0x04, 0x2f, 0x80, 0x91, 0x0a, 0x2f, 0x86, 0x6f, 0x73, 0x0f, 0x07, 0x2f, 0x83, 0x6f, 0xc0, + 0xb2, 0x04, 0x2f, 0x54, 0x42, 0x45, 0x42, 0x12, 0x30, 0x04, 0x2c, 0x11, 0x30, 0x02, 0x2c, 0x11, 0x30, 0x11, 0x30, + 0x02, 0xbc, 0x0f, 0xb8, 0xd2, 0x7f, 0x00, 0xb2, 0x0a, 0x2f, 0x01, 0x2e, 0xfc, 0x00, 0x05, 0x2e, 0xc7, 0x01, 0x10, + 0x1a, 0x02, 0x2f, 0x21, 0x2e, 0xc7, 0x01, 0x03, 0x2d, 0x02, 0x2c, 0x01, 0x30, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xd1, 0x6f, 0xa0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0xe2, 0x6f, 0x9f, 0x52, 0x01, 0x2e, 0xce, 0x00, 0x82, + 0x40, 0x50, 0x42, 0x0c, 0x2c, 0x42, 0x42, 0x11, 0x30, 0x23, 0x2e, 0xd2, 0x00, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xa0, 0x6f, 0x01, 0x30, 0x98, 0x2e, 0x95, 0xcf, 0x00, 0x2e, 0xfb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x83, + 0x86, 0x01, 0x30, 0x00, 0x30, 0x94, 0x40, 0x24, 0x18, 0x06, 0x00, 0x53, 0x0e, 0x4f, 0x02, 0xf9, 0x2f, 0xb8, 0x2e, + 0xa9, 0x52, 0x00, 0x2e, 0x60, 0x40, 0x41, 0x40, 0x0d, 0xbc, 0x98, 0xbc, 0xc0, 0x2e, 0x01, 0x0a, 0x0f, 0xb8, 0xab, + 0x52, 0x53, 0x3c, 0x52, 0x40, 0x40, 0x40, 0x4b, 0x00, 0x82, 0x16, 0x26, 0xb9, 0x01, 0xb8, 0x41, 0x40, 0x10, 0x08, + 0x97, 0xb8, 0x01, 0x08, 0xc0, 0x2e, 0x11, 0x30, 0x01, 0x08, 0x43, 0x86, 0x25, 0x40, 0x04, 0x40, 0xd8, 0xbe, 0x2c, + 0x0b, 0x22, 0x11, 0x54, 0x42, 0x03, 0x80, 0x4b, 0x0e, 0xf6, 0x2f, 0xb8, 0x2e, 0x9f, 0x50, 0x10, 0x50, 0xad, 0x52, + 0x05, 0x2e, 0xd3, 0x00, 0xfb, 0x7f, 0x00, 0x2e, 0x13, 0x40, 0x93, 0x42, 0x41, 0x0e, 0xfb, 0x2f, 0x98, 0x2e, 0xa5, + 0xb7, 0x98, 0x2e, 0x87, 0xcf, 0x01, 0x2e, 0xd9, 0x00, 0x00, 0xb2, 0xfb, 0x6f, 0x0b, 0x2f, 0x01, 0x2e, 0x69, 0xf7, + 0xb1, 0x3f, 0x01, 0x08, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd9, 0x00, 0x21, 0x2e, 0x69, 0xf7, 0x80, 0x2e, 0x7a, + 0xb7, 0xf0, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xc0, 0xf8, 0x03, 0x2e, 0xfc, 0xf5, 0x15, 0x54, 0xaf, 0x56, 0x82, 0x08, + 0x0b, 0x2e, 0x69, 0xf7, 0xcb, 0x0a, 0xb1, 0x58, 0x80, 0x90, 0xdd, 0xbe, 0x4c, 0x08, 0x5f, 0xb9, 0x59, 0x22, 0x80, + 0x90, 0x07, 0x2f, 0x03, 0x34, 0xc3, 0x08, 0xf2, 0x3a, 0x0a, 0x08, 0x02, 0x35, 0xc0, 0x90, 0x4a, 0x0a, 0x48, 0x22, + 0xc0, 0x2e, 0x23, 0x2e, 0xfc, 0xf5, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x56, 0xc7, 0x98, 0x2e, 0x49, 0xc3, 0x10, + 0x30, 0xfb, 0x6f, 0xf0, 0x5f, 0x21, 0x2e, 0xcc, 0x00, 0x21, 0x2e, 0xca, 0x00, 0xb8, 0x2e, 0x03, 0x2e, 0xd3, 0x00, + 0x16, 0xb8, 0x02, 0x34, 0x4a, 0x0c, 0x21, 0x2e, 0x2d, 0xf5, 0xc0, 0x2e, 0x23, 0x2e, 0xd3, 0x00, 0x03, 0xbc, 0x21, + 0x2e, 0xd5, 0x00, 0x03, 0x2e, 0xd5, 0x00, 0x40, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x30, 0x05, 0x2f, + 0x05, 0x2e, 0xd8, 0x00, 0x80, 0x90, 0x01, 0x2f, 0x23, 0x2e, 0x6f, 0xf5, 0xc0, 0x2e, 0x21, 0x2e, 0xd9, 0x00, 0x11, + 0x30, 0x81, 0x08, 0x01, 0x2e, 0x6a, 0xf7, 0x71, 0x3f, 0x23, 0xbd, 0x01, 0x08, 0x02, 0x0a, 0xc0, 0x2e, 0x21, 0x2e, + 0x6a, 0xf7, 0x30, 0x25, 0x00, 0x30, 0x21, 0x2e, 0x5a, 0xf5, 0x10, 0x50, 0x21, 0x2e, 0x7b, 0x00, 0x21, 0x2e, 0x7c, + 0x00, 0xfb, 0x7f, 0x98, 0x2e, 0xc3, 0xb7, 0x40, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0x03, 0x25, + 0x80, 0x2e, 0xaf, 0xb7, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x01, 0x2e, 0x5d, 0xf7, 0x08, 0xbc, 0x80, 0xac, 0x0e, 0xbb, 0x02, 0x2f, + 0x00, 0x30, 0x41, 0x04, 0x82, 0x06, 0xc0, 0xa4, 0x00, 0x30, 0x11, 0x2f, 0x40, 0xa9, 0x03, 0x2f, 0x40, 0x91, 0x0d, + 0x2f, 0x00, 0xa7, 0x0b, 0x2f, 0x80, 0xb3, 0xb3, 0x58, 0x02, 0x2f, 0x90, 0xa1, 0x26, 0x13, 0x20, 0x23, 0x80, 0x90, + 0x10, 0x30, 0x01, 0x2f, 0xcc, 0x0e, 0x00, 0x2f, 0x00, 0x30, 0xb8, 0x2e, 0xb5, 0x50, 0x18, 0x08, 0x08, 0xbc, 0x88, + 0xb6, 0x0d, 0x17, 0xc6, 0xbd, 0x56, 0xbc, 0xb7, 0x58, 0xda, 0xba, 0x04, 0x01, 0x1d, 0x0a, 0x10, 0x50, 0x05, 0x30, + 0x32, 0x25, 0x45, 0x03, 0xfb, 0x7f, 0xf6, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x37, 0xca, 0x16, 0xb5, 0x9a, 0xbc, 0x06, + 0xb8, 0x80, 0xa8, 0x41, 0x0a, 0x0e, 0x2f, 0x80, 0x90, 0x02, 0x2f, 0x2d, 0x50, 0x48, 0x0f, 0x09, 0x2f, 0xbf, 0xa0, + 0x04, 0x2f, 0xbf, 0x90, 0x06, 0x2f, 0xb7, 0x54, 0xca, 0x0f, 0x03, 0x2f, 0x00, 0x2e, 0x02, 0x2c, 0xb7, 0x52, 0x2d, + 0x52, 0xf2, 0x33, 0x98, 0x2e, 0xd9, 0xc0, 0xfb, 0x6f, 0xf1, 0x37, 0xc0, 0x2e, 0x01, 0x08, 0xf0, 0x5f, 0xbf, 0x56, + 0xb9, 0x54, 0xd0, 0x40, 0xc4, 0x40, 0x0b, 0x2e, 0xfd, 0xf3, 0xbf, 0x52, 0x90, 0x42, 0x94, 0x42, 0x95, 0x42, 0x05, + 0x30, 0xc1, 0x50, 0x0f, 0x88, 0x06, 0x40, 0x04, 0x41, 0x96, 0x42, 0xc5, 0x42, 0x48, 0xbe, 0x73, 0x30, 0x0d, 0x2e, + 0xd8, 0x00, 0x4f, 0xba, 0x84, 0x42, 0x03, 0x42, 0x81, 0xb3, 0x02, 0x2f, 0x2b, 0x2e, 0x6f, 0xf5, 0x06, 0x2d, 0x05, + 0x2e, 0x77, 0xf7, 0xbd, 0x56, 0x93, 0x08, 0x25, 0x2e, 0x77, 0xf7, 0xbb, 0x54, 0x25, 0x2e, 0xc2, 0xf5, 0x07, 0x2e, + 0xfd, 0xf3, 0x42, 0x30, 0xb4, 0x33, 0xda, 0x0a, 0x4c, 0x00, 0x27, 0x2e, 0xfd, 0xf3, 0x43, 0x40, 0xd4, 0x3f, 0xdc, + 0x08, 0x43, 0x42, 0x00, 0x2e, 0x00, 0x2e, 0x43, 0x40, 0x24, 0x30, 0xdc, 0x0a, 0x43, 0x42, 0x04, 0x80, 0x03, 0x2e, + 0xfd, 0xf3, 0x4a, 0x0a, 0x23, 0x2e, 0xfd, 0xf3, 0x61, 0x34, 0xc0, 0x2e, 0x01, 0x42, 0x00, 0x2e, 0x60, 0x50, 0x1a, + 0x25, 0x7a, 0x86, 0xe0, 0x7f, 0xf3, 0x7f, 0x03, 0x25, 0xc3, 0x52, 0x41, 0x84, 0xdb, 0x7f, 0x33, 0x30, 0x98, 0x2e, + 0x16, 0xc2, 0x1a, 0x25, 0x7d, 0x82, 0xf0, 0x6f, 0xe2, 0x6f, 0x32, 0x25, 0x16, 0x40, 0x94, 0x40, 0x26, 0x01, 0x85, + 0x40, 0x8e, 0x17, 0xc4, 0x42, 0x6e, 0x03, 0x95, 0x42, 0x41, 0x0e, 0xf4, 0x2f, 0xdb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, + 0xb0, 0x51, 0xfb, 0x7f, 0x98, 0x2e, 0xe8, 0x0d, 0x5a, 0x25, 0x98, 0x2e, 0x0f, 0x0e, 0xcb, 0x58, 0x32, 0x87, 0xc4, + 0x7f, 0x65, 0x89, 0x6b, 0x8d, 0xc5, 0x5a, 0x65, 0x7f, 0xe1, 0x7f, 0x83, 0x7f, 0xa6, 0x7f, 0x74, 0x7f, 0xd0, 0x7f, + 0xb6, 0x7f, 0x94, 0x7f, 0x17, 0x30, 0xc7, 0x52, 0xc9, 0x54, 0x51, 0x7f, 0x00, 0x2e, 0x85, 0x6f, 0x42, 0x7f, 0x00, + 0x2e, 0x51, 0x41, 0x45, 0x81, 0x42, 0x41, 0x13, 0x40, 0x3b, 0x8a, 0x00, 0x40, 0x4b, 0x04, 0xd0, 0x06, 0xc0, 0xac, + 0x85, 0x7f, 0x02, 0x2f, 0x02, 0x30, 0x51, 0x04, 0xd3, 0x06, 0x41, 0x84, 0x05, 0x30, 0x5d, 0x02, 0xc9, 0x16, 0xdf, + 0x08, 0xd3, 0x00, 0x8d, 0x02, 0xaf, 0xbc, 0xb1, 0xb9, 0x59, 0x0a, 0x65, 0x6f, 0x11, 0x43, 0xa1, 0xb4, 0x52, 0x41, + 0x53, 0x41, 0x01, 0x43, 0x34, 0x7f, 0x65, 0x7f, 0x26, 0x31, 0xe5, 0x6f, 0xd4, 0x6f, 0x98, 0x2e, 0x37, 0xca, 0x32, + 0x6f, 0x75, 0x6f, 0x83, 0x40, 0x42, 0x41, 0x23, 0x7f, 0x12, 0x7f, 0xf6, 0x30, 0x40, 0x25, 0x51, 0x25, 0x98, 0x2e, + 0x37, 0xca, 0x14, 0x6f, 0x20, 0x05, 0x70, 0x6f, 0x25, 0x6f, 0x69, 0x07, 0xa2, 0x6f, 0x31, 0x6f, 0x0b, 0x30, 0x04, + 0x42, 0x9b, 0x42, 0x8b, 0x42, 0x55, 0x42, 0x32, 0x7f, 0x40, 0xa9, 0xc3, 0x6f, 0x71, 0x7f, 0x02, 0x30, 0xd0, 0x40, + 0xc3, 0x7f, 0x03, 0x2f, 0x40, 0x91, 0x15, 0x2f, 0x00, 0xa7, 0x13, 0x2f, 0x00, 0xa4, 0x11, 0x2f, 0x84, 0xbd, 0x98, + 0x2e, 0x79, 0xca, 0x55, 0x6f, 0xb7, 0x54, 0x54, 0x41, 0x82, 0x00, 0xf3, 0x3f, 0x45, 0x41, 0xcb, 0x02, 0xf6, 0x30, + 0x98, 0x2e, 0x37, 0xca, 0x35, 0x6f, 0xa4, 0x6f, 0x41, 0x43, 0x03, 0x2c, 0x00, 0x43, 0xa4, 0x6f, 0x35, 0x6f, 0x17, + 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x93, 0x40, 0x42, 0x82, 0x00, 0x41, 0xc3, 0x00, 0x03, 0x43, 0x51, 0x7f, 0x00, 0x2e, + 0x94, 0x40, 0x41, 0x41, 0x4c, 0x02, 0xc4, 0x6f, 0xd1, 0x56, 0x63, 0x0e, 0x74, 0x6f, 0x51, 0x43, 0xa5, 0x7f, 0x8a, + 0x2f, 0x09, 0x2e, 0xd8, 0x00, 0x01, 0xb3, 0x21, 0x2f, 0xcb, 0x58, 0x90, 0x6f, 0x13, 0x41, 0xb6, 0x6f, 0xe4, 0x7f, + 0x00, 0x2e, 0x91, 0x41, 0x14, 0x40, 0x92, 0x41, 0x15, 0x40, 0x17, 0x2e, 0x6f, 0xf5, 0xb6, 0x7f, 0xd0, 0x7f, 0xcb, + 0x7f, 0x98, 0x2e, 0x00, 0x0c, 0x07, 0x15, 0xc2, 0x6f, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0xc3, 0xa3, 0xc1, 0x8f, + 0xe4, 0x6f, 0xd0, 0x6f, 0xe6, 0x2f, 0x14, 0x30, 0x05, 0x2e, 0x6f, 0xf5, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0x18, + 0x2d, 0xcd, 0x56, 0x04, 0x32, 0xb5, 0x6f, 0x1c, 0x01, 0x51, 0x41, 0x52, 0x41, 0xc3, 0x40, 0xb5, 0x7f, 0xe4, 0x7f, + 0x98, 0x2e, 0x1f, 0x0c, 0xe4, 0x6f, 0x21, 0x87, 0x00, 0x43, 0x04, 0x32, 0xcf, 0x54, 0x5a, 0x0e, 0xef, 0x2f, 0x15, + 0x54, 0x09, 0x2e, 0x77, 0xf7, 0x22, 0x0b, 0x29, 0x2e, 0x77, 0xf7, 0xfb, 0x6f, 0x50, 0x5e, 0xb8, 0x2e, 0x10, 0x50, + 0x01, 0x2e, 0xd4, 0x00, 0x00, 0xb2, 0xfb, 0x7f, 0x51, 0x2f, 0x01, 0xb2, 0x48, 0x2f, 0x02, 0xb2, 0x42, 0x2f, 0x03, + 0x90, 0x56, 0x2f, 0xd7, 0x52, 0x79, 0x80, 0x42, 0x40, 0x81, 0x84, 0x00, 0x40, 0x42, 0x42, 0x98, 0x2e, 0x93, 0x0c, + 0xd9, 0x54, 0xd7, 0x50, 0xa1, 0x40, 0x98, 0xbd, 0x82, 0x40, 0x3e, 0x82, 0xda, 0x0a, 0x44, 0x40, 0x8b, 0x16, 0xe3, + 0x00, 0x53, 0x42, 0x00, 0x2e, 0x43, 0x40, 0x9a, 0x02, 0x52, 0x42, 0x00, 0x2e, 0x41, 0x40, 0x15, 0x54, 0x4a, 0x0e, + 0x3a, 0x2f, 0x3a, 0x82, 0x00, 0x30, 0x41, 0x40, 0x21, 0x2e, 0x85, 0x0f, 0x40, 0xb2, 0x0a, 0x2f, 0x98, 0x2e, 0xb1, + 0x0c, 0x98, 0x2e, 0x45, 0x0e, 0x98, 0x2e, 0x5b, 0x0e, 0xfb, 0x6f, 0xf0, 0x5f, 0x00, 0x30, 0x80, 0x2e, 0xce, 0xb7, + 0xdd, 0x52, 0xd3, 0x54, 0x42, 0x42, 0x4f, 0x84, 0x73, 0x30, 0xdb, 0x52, 0x83, 0x42, 0x1b, 0x30, 0x6b, 0x42, 0x23, + 0x30, 0x27, 0x2e, 0xd7, 0x00, 0x37, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0xd6, 0x00, 0x7a, 0x84, 0x17, 0x2c, 0x42, 0x42, + 0x30, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x12, 0x2d, 0x21, 0x30, 0x00, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0x7b, + 0xf7, 0x0b, 0x2d, 0x17, 0x30, 0x98, 0x2e, 0x51, 0x0c, 0xd5, 0x50, 0x0c, 0x82, 0x72, 0x30, 0x2f, 0x2e, 0xd4, 0x00, + 0x25, 0x2e, 0x7b, 0xf7, 0x40, 0x42, 0x00, 0x2e, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, 0x70, 0x50, 0x0a, 0x25, 0x39, + 0x86, 0xfb, 0x7f, 0xe1, 0x32, 0x62, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xb5, 0x56, 0xa5, 0x6f, 0xab, 0x08, 0x91, 0x6f, + 0x4b, 0x08, 0xdf, 0x56, 0xc4, 0x6f, 0x23, 0x09, 0x4d, 0xba, 0x93, 0xbc, 0x8c, 0x0b, 0xd1, 0x6f, 0x0b, 0x09, 0xcb, + 0x52, 0xe1, 0x5e, 0x56, 0x42, 0xaf, 0x09, 0x4d, 0xba, 0x23, 0xbd, 0x94, 0x0a, 0xe5, 0x6f, 0x68, 0xbb, 0xeb, 0x08, + 0xbd, 0xb9, 0x63, 0xbe, 0xfb, 0x6f, 0x52, 0x42, 0xe3, 0x0a, 0xc0, 0x2e, 0x43, 0x42, 0x90, 0x5f, 0xd1, 0x50, 0x03, + 0x2e, 0x25, 0xf3, 0x13, 0x40, 0x00, 0x40, 0x9b, 0xbc, 0x9b, 0xb4, 0x08, 0xbd, 0xb8, 0xb9, 0x98, 0xbc, 0xda, 0x0a, + 0x08, 0xb6, 0x89, 0x16, 0xc0, 0x2e, 0x19, 0x00, 0x62, 0x02, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x81, 0x0d, 0x01, + 0x2e, 0xd4, 0x00, 0x31, 0x30, 0x08, 0x04, 0xfb, 0x6f, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd6, 0x00, 0x21, 0x2e, + 0xd7, 0x00, 0xb8, 0x2e, 0x01, 0x2e, 0xd7, 0x00, 0x03, 0x2e, 0xd6, 0x00, 0x48, 0x0e, 0x01, 0x2f, 0x80, 0x2e, 0x1f, + 0x0e, 0xb8, 0x2e, 0xe3, 0x50, 0x21, 0x34, 0x01, 0x42, 0x82, 0x30, 0xc1, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x01, 0x00, + 0x22, 0x30, 0x01, 0x40, 0x4a, 0x0a, 0x01, 0x42, 0xb8, 0x2e, 0xe3, 0x54, 0xf0, 0x3b, 0x83, 0x40, 0xd8, 0x08, 0xe5, + 0x52, 0x83, 0x42, 0x00, 0x30, 0x83, 0x30, 0x50, 0x42, 0xc4, 0x32, 0x27, 0x2e, 0x64, 0xf5, 0x94, 0x00, 0x50, 0x42, + 0x40, 0x42, 0xd3, 0x3f, 0x84, 0x40, 0x7d, 0x82, 0xe3, 0x08, 0x40, 0x42, 0x83, 0x42, 0xb8, 0x2e, 0xdd, 0x52, 0x00, + 0x30, 0x40, 0x42, 0x7c, 0x86, 0xb9, 0x52, 0x09, 0x2e, 0x70, 0x0f, 0xbf, 0x54, 0xc4, 0x42, 0xd3, 0x86, 0x54, 0x40, + 0x55, 0x40, 0x94, 0x42, 0x85, 0x42, 0x21, 0x2e, 0xd7, 0x00, 0x42, 0x40, 0x25, 0x2e, 0xfd, 0xf3, 0xc0, 0x42, 0x7e, + 0x82, 0x05, 0x2e, 0x7d, 0x00, 0x80, 0xb2, 0x14, 0x2f, 0x05, 0x2e, 0x89, 0x00, 0x27, 0xbd, 0x2f, 0xb9, 0x80, 0x90, + 0x02, 0x2f, 0x21, 0x2e, 0x6f, 0xf5, 0x0c, 0x2d, 0x07, 0x2e, 0x71, 0x0f, 0x14, 0x30, 0x1c, 0x09, 0x05, 0x2e, 0x77, + 0xf7, 0xbd, 0x56, 0x47, 0xbe, 0x93, 0x08, 0x94, 0x0a, 0x25, 0x2e, 0x77, 0xf7, 0xe7, 0x54, 0x50, 0x42, 0x4a, 0x0e, + 0xfc, 0x2f, 0xb8, 0x2e, 0x50, 0x50, 0x02, 0x30, 0x43, 0x86, 0xe5, 0x50, 0xfb, 0x7f, 0xe3, 0x7f, 0xd2, 0x7f, 0xc0, + 0x7f, 0xb1, 0x7f, 0x00, 0x2e, 0x41, 0x40, 0x00, 0x40, 0x48, 0x04, 0x98, 0x2e, 0x74, 0xc0, 0x1e, 0xaa, 0xd3, 0x6f, + 0x14, 0x30, 0xb1, 0x6f, 0xe3, 0x22, 0xc0, 0x6f, 0x52, 0x40, 0xe4, 0x6f, 0x4c, 0x0e, 0x12, 0x42, 0xd3, 0x7f, 0xeb, + 0x2f, 0x03, 0x2e, 0x86, 0x0f, 0x40, 0x90, 0x11, 0x30, 0x03, 0x2f, 0x23, 0x2e, 0x86, 0x0f, 0x02, 0x2c, 0x00, 0x30, + 0xd0, 0x6f, 0xfb, 0x6f, 0xb0, 0x5f, 0xb8, 0x2e, 0x40, 0x50, 0xf1, 0x7f, 0x0a, 0x25, 0x3c, 0x86, 0xeb, 0x7f, 0x41, + 0x33, 0x22, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xd3, 0x6f, 0xf4, 0x30, 0xdc, 0x09, 0x47, 0x58, 0xc2, 0x6f, 0x94, 0x09, + 0xeb, 0x58, 0x6a, 0xbb, 0xdc, 0x08, 0xb4, 0xb9, 0xb1, 0xbd, 0xe9, 0x5a, 0x95, 0x08, 0x21, 0xbd, 0xf6, 0xbf, 0x77, + 0x0b, 0x51, 0xbe, 0xf1, 0x6f, 0xeb, 0x6f, 0x52, 0x42, 0x54, 0x42, 0xc0, 0x2e, 0x43, 0x42, 0xc0, 0x5f, 0x50, 0x50, + 0xf5, 0x50, 0x31, 0x30, 0x11, 0x42, 0xfb, 0x7f, 0x7b, 0x30, 0x0b, 0x42, 0x11, 0x30, 0x02, 0x80, 0x23, 0x33, 0x01, + 0x42, 0x03, 0x00, 0x07, 0x2e, 0x80, 0x03, 0x05, 0x2e, 0xd3, 0x00, 0x23, 0x52, 0xe2, 0x7f, 0xd3, 0x7f, 0xc0, 0x7f, + 0x98, 0x2e, 0xb6, 0x0e, 0xd1, 0x6f, 0x08, 0x0a, 0x1a, 0x25, 0x7b, 0x86, 0xd0, 0x7f, 0x01, 0x33, 0x12, 0x30, 0x98, + 0x2e, 0xc2, 0xc4, 0xd1, 0x6f, 0x08, 0x0a, 0x00, 0xb2, 0x0d, 0x2f, 0xe3, 0x6f, 0x01, 0x2e, 0x80, 0x03, 0x51, 0x30, + 0xc7, 0x86, 0x23, 0x2e, 0x21, 0xf2, 0x08, 0xbc, 0xc0, 0x42, 0x98, 0x2e, 0xa5, 0xb7, 0x00, 0x2e, 0x00, 0x2e, 0xd0, + 0x2e, 0xb0, 0x6f, 0x0b, 0xb8, 0x03, 0x2e, 0x1b, 0x00, 0x08, 0x1a, 0xb0, 0x7f, 0x70, 0x30, 0x04, 0x2f, 0x21, 0x2e, + 0x21, 0xf2, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x98, 0x2e, 0x6d, 0xc0, 0x98, 0x2e, 0x5d, 0xc0, 0xed, 0x50, 0x98, + 0x2e, 0x44, 0xcb, 0xef, 0x50, 0x98, 0x2e, 0x46, 0xc3, 0xf1, 0x50, 0x98, 0x2e, 0x53, 0xc7, 0x35, 0x50, 0x98, 0x2e, + 0x64, 0xcf, 0x10, 0x30, 0x98, 0x2e, 0xdc, 0x03, 0x20, 0x26, 0xc0, 0x6f, 0x02, 0x31, 0x12, 0x42, 0xab, 0x33, 0x0b, + 0x42, 0x37, 0x80, 0x01, 0x30, 0x01, 0x42, 0xf3, 0x37, 0xf7, 0x52, 0xfb, 0x50, 0x44, 0x40, 0xa2, 0x0a, 0x42, 0x42, + 0x8b, 0x31, 0x09, 0x2e, 0x5e, 0xf7, 0xf9, 0x54, 0xe3, 0x08, 0x83, 0x42, 0x1b, 0x42, 0x23, 0x33, 0x4b, 0x00, 0xbc, + 0x84, 0x0b, 0x40, 0x33, 0x30, 0x83, 0x42, 0x0b, 0x42, 0xe0, 0x7f, 0xd1, 0x7f, 0x98, 0x2e, 0x58, 0xb7, 0xd1, 0x6f, + 0x80, 0x30, 0x40, 0x42, 0x03, 0x30, 0xe0, 0x6f, 0xf3, 0x54, 0x04, 0x30, 0x00, 0x2e, 0x00, 0x2e, 0x01, 0x89, 0x62, + 0x0e, 0xfa, 0x2f, 0x43, 0x42, 0x11, 0x30, 0xfb, 0x6f, 0xc0, 0x2e, 0x01, 0x42, 0xb0, 0x5f, 0xc1, 0x4a, 0x00, 0x00, + 0x6d, 0x57, 0x00, 0x00, 0x77, 0x8e, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xd3, 0xff, 0xff, 0xff, 0xe5, 0xff, 0xff, + 0xff, 0xee, 0xe1, 0xff, 0xff, 0x7c, 0x13, 0x00, 0x00, 0x46, 0xe6, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1 + +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py new file mode 100644 index 0000000000..c1616665f9 --- /dev/null +++ b/esphome/components/bmi270/motion.py @@ -0,0 +1,91 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import BMI270Component, bmi270_ns + +DEPENDENCIES = ["i2c"] + +# Enum proxies (must match the C++ enum values exactly) +BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, + "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, + "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, + "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, +} + +BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") +GYRO_RANGE_OPTIONS = { + "2000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_2000, + "1000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_1000, + "500DPS": BMI270GyroRange.BMI270_GYRO_RANGE_500, + "250DPS": BMI270GyroRange.BMI270_GYRO_RANGE_250, + "125DPS": BMI270GyroRange.BMI270_GYRO_RANGE_125, +} + +BMI270AccelODR = bmi270_ns.enum("BMI270AccelODR") +ACCEL_ODR_OPTIONS = { + "12_5HZ": BMI270AccelODR.BMI270_ACCEL_ODR_12_5, + "25HZ": BMI270AccelODR.BMI270_ACCEL_ODR_25, + "50HZ": BMI270AccelODR.BMI270_ACCEL_ODR_50, + "100HZ": BMI270AccelODR.BMI270_ACCEL_ODR_100, + "200HZ": BMI270AccelODR.BMI270_ACCEL_ODR_200, + "400HZ": BMI270AccelODR.BMI270_ACCEL_ODR_400, + "800HZ": BMI270AccelODR.BMI270_ACCEL_ODR_800, + "1600HZ": BMI270AccelODR.BMI270_ACCEL_ODR_1600, +} + +BMI270GyroODR = bmi270_ns.enum("BMI270GyroODR") +GYRO_ODR_OPTIONS = { + "25HZ": BMI270GyroODR.BMI270_GYRO_ODR_25, + "50HZ": BMI270GyroODR.BMI270_GYRO_ODR_50, + "100HZ": BMI270GyroODR.BMI270_GYRO_ODR_100, + "200HZ": BMI270GyroODR.BMI270_GYRO_ODR_200, + "400HZ": BMI270GyroODR.BMI270_GYRO_ODR_400, + "800HZ": BMI270GyroODR.BMI270_GYRO_ODR_800, + "1600HZ": BMI270GyroODR.BMI270_GYRO_ODR_1600, + "3200HZ": BMI270GyroODR.BMI270_GYRO_ODR_3200, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(BMI270Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x68)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Accelerometer sensors + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py new file mode 100644 index 0000000000..69235ed8dc --- /dev/null +++ b/esphome/components/bmi270/sensor.py @@ -0,0 +1,41 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_BMI270_ID, BMI270Component + +AUTO_LOAD = ["bmi270"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_BMI270_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index ebb4186a2b..9951243f0d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -5,6 +5,8 @@ CODEOWNERS = ["@esphome/core"] BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" +CONF_ACCELEROMETER_ODR = "accelerometer_odr" +CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" @@ -13,6 +15,8 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" +CONF_GYROSCOPE_ODR = "gyroscope_odr" +CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml new file mode 100644 index 0000000000..0ffb1c6281 --- /dev/null +++ b/tests/components/bmi270/common.yaml @@ -0,0 +1,68 @@ +sensor: + - platform: bmi270 + name: "BMI270 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: bmi270 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 12_5HZ | 25HZ | 50HZ | 100HZ | + # 200HZ | 400HZ | 800HZ | 1600HZ + accelerometer_odr: 100HZ + + # Gyroscope full-scale range: 125DPS | 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 2000DPS + + # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | + # 400HZ | 800HZ | 1600HZ | 3200HZ + gyroscope_odr: 200HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 4963ddcb95d777b0753bf7ecaca8b43b280fe45e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:09:50 -0400 Subject: [PATCH 272/282] [espidf] Fix idedata generation on Windows (#16894) --- esphome/espidf/idedata.py | 66 ++++++++++++++++++++--- tests/unit_tests/test_espidf_idedata.py | 70 ++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 6fce8a55d9..0ed357a759 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -29,6 +29,54 @@ _INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") _ESPHOME_SRC_MARKER = "/src/esphome/" +def _is_esphome_src(file: str) -> bool: + """Whether ``file`` is an ESPHome C++ translation unit. + + ``compile_commands.json`` ``file`` paths use the OS-native separator, so on + Windows they contain backslashes; normalize to ``/`` before testing the + marker, otherwise no source matches and the build-include union is empty. + """ + return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( + _CXX_SUFFIXES + ) + + +def _split_command(command: str) -> list[str]: + r"""Tokenize a compile_commands.json / response-file command string. + + On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``. + ESP-IDF's compile_commands.json there mixes two backslash conventions in one + string: literal path separators in the compiler path (``C:\Users\...g++.exe``, + no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``). + Only the real Windows parser — where a backslash escapes solely a following + quote — handles both, and it is the exact tokenizer the compiler is launched + with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling + its escape mangles the defines. + """ + if os.name != "nt": + return shlex.split(command) + + import ctypes + from ctypes import wintypes + + # CommandLineToArgvW("") returns the current process name, not []; guard it + # so an empty response file tokenizes the same as it would via shlex. + if not command.strip(): + return [] + + CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + argc = ctypes.c_int() + argv = CommandLineToArgvW(command, ctypes.byref(argc)) + if not argv: # pragma: no cover + raise ctypes.WinError() + try: + return [argv[i] for i in range(argc.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: """Inline any ``@response-file`` arguments (paths relative to ``directory``). @@ -45,7 +93,7 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: try: out.extend( _expand_response_files( - shlex.split(rf.read_text(encoding="utf-8")), directory + _split_command(rf.read_text(encoding="utf-8")), directory ) ) continue @@ -64,8 +112,7 @@ def _pick_entry(entries: list[dict]) -> dict: them yields the cxx_path / cxx_flags / defines we need. """ for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES): + if _is_esphome_src(entry["file"]): return entry for entry in entries: if entry["file"].endswith(_CXX_SUFFIXES): @@ -76,18 +123,22 @@ def _pick_entry(entries: list[dict]) -> dict: def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" directory = Path(entry["directory"]) - tokens = _expand_response_files(shlex.split(entry["command"]), directory) + tokens = _expand_response_files(_split_command(entry["command"]), directory) def _include(raw: str) -> str: # Include paths in compile_commands are interpreted relative to the # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them # so the cached idedata is usable regardless of the consumer's cwd. + # Emit forward slashes (``normpath`` yields ``\`` on Windows) so the + # paths match the absolute, already-forward-slash entries in the JSON. raw = raw.strip() if raw and not Path(raw).is_absolute(): raw = os.path.normpath(directory / raw) - return raw + return raw.replace("\\", "/") - cxx_path = tokens[0] + # token0 is the compiler path; the rest of the command already uses forward + # slashes on Windows, so normalize it too for a consistent idedata file. + cxx_path = tokens[0].replace("\\", "/") defines: list[str] = [] includes: list[str] = [] cxx_flags: list[str] = [] @@ -161,8 +212,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes: dict[str, None] = {} for entry in entries: - f = entry["file"] - if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES): + if not _is_esphome_src(entry["file"]): continue for inc in _parse_entry(entry)[2]: build_includes.setdefault(inc, None) diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index 849ef274ed..1088517ed1 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -72,7 +72,9 @@ def test_parse_entry_resolves_relative_includes() -> None: _, _, includes, _ = idedata._parse_entry(entry) def resolved(rel: str) -> str: - return os.path.normpath(Path(directory) / rel) + # _parse_entry emits forward slashes for consistency (normpath would + # yield backslashes on Windows). + return os.path.normpath(Path(directory) / rel).replace("\\", "/") assert resolved("config") in includes assert resolved("../shared") in includes # ../ normalized away @@ -124,6 +126,29 @@ def test_pick_entry_prefers_esphome_tu() -> None: assert idedata._pick_entry(entries)["file"].endswith("app.cpp") +def test_pick_entry_falls_back_to_any_cxx_tu() -> None: + """With no ``/src/esphome/`` TU present, the first C++ entry is the fallback.""" + entries = [ + _entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"), + _entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"), + ] + assert idedata._pick_entry(entries)["file"].endswith("x.cpp") + + +def test_is_esphome_src_handles_backslash_paths() -> None: + r"""The src marker must match Windows ``\src\esphome\`` paths too. + + compile_commands ``file`` entries use the OS-native separator; if the + marker only matched forward slashes no source would match on Windows and + the build-include union would be silently empty. + """ + assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp") + assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp") + # non-esphome and non-C++ still rejected regardless of separator + assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp") + assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h") + + def test_idedata_from_build(tmp_path: Path) -> None: """Full transform: representative entry + include union + toolchain dirs.""" compile_commands = tmp_path / "compile_commands.json" @@ -194,3 +219,46 @@ def test_get_toolchain_includes_raises_when_no_dirs_found() -> None: pytest.raises(RuntimeError, match="builtin include dirs"), ): idedata._get_toolchain_includes("/some/compiler") + + +# ESP-IDF's compile_commands.json on Windows mixes literal backslash path +# separators in the compiler path with shell ``\"`` quote-escaping in defines, +# which only the real Windows argv parser handles. These exercise that path. +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_preserves_paths_and_unescapes_quotes() -> None: + r"""Backslash paths survive while ``\"`` define-quoting is unescaped.""" + command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp" + + tokens = idedata._split_command(command) + + assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe" + assert '-DVER="1.2.3"' in tokens + assert "-IC:/inc/a" in tokens + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_split_command_empty_returns_empty() -> None: + """An empty or blank command tokenizes to ``[]`` (e.g. an empty response file). + + Guards against ``CommandLineToArgvW("")`` returning the current process name + instead of an empty list. + """ + assert idedata._split_command("") == [] + assert idedata._split_command(" ") == [] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") +def test_parse_entry_normalizes_windows_cxx_path() -> None: + """A backslash compiler path is emitted forward-slashed; define unescaped.""" + entry = _entry( + r"C:\b", + r"C:\b\src\esphome\x.cpp", + r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp", + ) + + cxx_path, defines, includes, _ = idedata._parse_entry(entry) + + assert cxx_path == "C:/esp/bin/g++.exe" + assert "\\" not in cxx_path + assert 'VER="1.2.3"' in defines + assert "C:/inc/a" in includes From e16a877745bcee26ce040109b35bc0872ef27b62 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:11:11 -0400 Subject: [PATCH 273/282] [platformio] De-duplicate non-ESP32 lib_deps into common:idf-component-libs (#16893) --- .clang-tidy.hash | 2 +- platformio.ini | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3c1c2be289..6f6339ff84 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da +442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 diff --git a/platformio.ini b/platformio.ini index d60a4fd68d..718dfb672f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -97,6 +97,16 @@ build_flags = build_unflags = ${common.build_unflags} +; Libraries shared by the non-ESP32 embedded environments (esp8266, rp2040, +; libretiny, nrf52). On ESP32 these are provided as ESP-IDF managed components +; via the esphome/idf_component.yml manifest, so they must not be listed in the +; esp32 envs (which would double-include them). +[common:idf-component-libs] +lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter + bblanchon/ArduinoJson@7.4.2 ; json + lvgl/lvgl@9.5.0 ; lvgl + ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] extends = common:arduino @@ -107,9 +117,8 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter + ${common:idf-component-libs.lib_deps} fastled/FastLED@3.9.16 ; fastled_base - bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp @@ -119,7 +128,6 @@ lib_deps = ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} @@ -194,12 +202,9 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} - esphome/dlms_parser@1.1.0 ; dlms_meter - fastled/FastLED@3.9.16 ; fastled_base + ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp - bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_RP2040 @@ -214,11 +219,9 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -239,9 +242,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} - esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json - lvgl/lvgl@9.5.0 ; lvgl + ${common:idf-component-libs.lib_deps} ; All the actual environments are defined below. From 6809af3de0a7369203287157a3388bd4d059b6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:12:28 -0400 Subject: [PATCH 274/282] [espidf] Warn when the install path is too long for Windows MAX_PATH (#16896) --- esphome/espidf/framework.py | 71 +++++++++++++ tests/unit_tests/test_espidf_framework.py | 118 ++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 1bc79cc412..c0e9a0051f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -85,6 +85,75 @@ def _get_idf_tools_path() -> Path: return CORE.data_dir / "idf" +# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply +# below the IDF tools directory: the longest file on disk (picolibc C++ +# headers) sits ~209 characters down, but the operative number is worse -- gcc +# probes its multilib include dirs via un-normalized self-relative paths +# ("bin/../lib/gcc///../../../..//include/..."), and +# Windows checks the path string as given, before collapsing "..". Measured +# worst case (riscv32, esp-15.2.0, longest multilib + no-rtti, probing +# bits/c++config.h): ~243 characters below the tools directory. Exceeding the +# limit surfaces as cryptic build failures -- missing headers ("fatal error: +# bits/c++config.h: No such file or directory") or partial extraction +# ("cannot execute 'as'"). Warn up front so the user can shorten the path or +# enable long path support. +_WINDOWS_MAX_PATH = 260 +# Measured 243 plus a small safety margin for future toolchain growth. +_TOOLCHAIN_NESTED_PATH_LEN = 245 + + +def _windows_long_paths_enabled() -> bool: + """Return True if Windows long path support is enabled in the registry.""" + try: + import winreg # pylint: disable=import-error # Windows-only module + + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem", + ) as key: + value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") + return value == 1 + except OSError: + return False + + +def _check_windows_path_length() -> None: + """Warn when the install path is too long for Windows' MAX_PATH limit. + + No-op off Windows or when long path support is enabled. Otherwise warns if + the deepest toolchain file would exceed the 260-character limit, which makes + ESP-IDF toolchains extract incompletely and fail to build. + """ + if platform.system() != "Windows" or _windows_long_paths_enabled(): + return + tools_path = str(_get_idf_tools_path()) + projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN + if projected <= _WINDOWS_MAX_PATH: + return + _LOGGER.warning( + "ESP-IDF tools path is too long for the default Windows path limit:\n" + " %s (%d characters)\n" + "ESP-IDF toolchain paths reach up to ~%d characters deeper (including the\n" + "compiler's internal 'bin/../lib/...' relative paths), projecting to ~%d\n" + "characters -- over the %d-character limit. This causes cryptic build\n" + "failures such as:\n" + " fatal error: bits/c++config.h: No such file or directory\n" + " cannot execute 'as': CreateProcess: No such file or directory\n" + "To fix, either:\n" + " - Enable Windows long path support: set\n" + " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" + " to 1 and reboot, or\n" + " - Move your ESPHome project to a shorter path\n" + "Then delete the ESP-IDF tools directory above so the toolchain " + "reinstalls cleanly.", + tools_path, + len(tools_path), + _TOOLCHAIN_NESTED_PATH_LEN, + projected, + _WINDOWS_MAX_PATH, + ) + + def _get_framework_path(version: str) -> Path: """ Get the path to the ESPHome ESP-IDF framework directory for a specific version. @@ -705,6 +774,8 @@ def check_esp_idf_install( Returns: tuple of (framework_path, python_env_path) """ + _check_windows_path_length() + env = {} env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) env["IDF_PATH"] = "" diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 036c7c0454..d89b93f478 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -2,9 +2,12 @@ # pylint: disable=protected-access +from contextlib import contextmanager import io import json +import logging from pathlib import Path +import sys import tarfile from types import SimpleNamespace from unittest.mock import patch @@ -13,6 +16,7 @@ import pytest from esphome.espidf.framework import ( _check_stamp, + _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, @@ -22,6 +26,7 @@ from esphome.espidf.framework import ( _get_python_version, _parse_git_source, _patch_tools_json_for_linux_arm64, + _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, check_esp_idf_install, @@ -682,3 +687,116 @@ def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised _write_idf_version_txt(tmp_path, "5.1.2") + + +def _fake_winreg( + query_result: int | None = None, query_error: OSError | None = None +) -> SimpleNamespace: + """Build a minimal winreg stand-in (the real module is Windows-only).""" + + @contextmanager + def open_key(root, path): + yield "hkey" + + def query_value_ex(key, name): + if query_error is not None: + raise query_error + return query_result, 4 # (value, REG_DWORD) + + return SimpleNamespace( + HKEY_LOCAL_MACHINE=object(), + OpenKey=open_key, + QueryValueEx=query_value_ex, + ) + + +@pytest.mark.parametrize(("reg_value", "expected"), [(1, True), (0, False)]) +def test_windows_long_paths_enabled_reads_registry( + reg_value: int, expected: bool +) -> None: + with patch.dict(sys.modules, {"winreg": _fake_winreg(query_result=reg_value)}): + assert _windows_long_paths_enabled() is expected + + +def test_windows_long_paths_enabled_missing_value() -> None: + """A missing registry value (FileNotFoundError is an OSError) reads as disabled.""" + fake = _fake_winreg(query_error=FileNotFoundError("no such value")) + with patch.dict(sys.modules, {"winreg": fake}): + assert _windows_long_paths_enabled() is False + + +# 8 chars -> projected well under the 260 limit even with the ~245-char reserve +_SHORT_IDF_PATH = "C:\\e\\idf" +# 25 chars -> projected over the limit +_LONG_IDF_PATH = "C:\\Users\\bob\\.esphome\\idf" + + +def test_check_windows_path_length_noop_off_windows( + caplog: pytest.LogCaptureFixture, +) -> None: + """Off Windows the check returns before touching the registry or the path.""" + with ( + patch("esphome.espidf.framework.platform.system", return_value="Linux"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled" + ) as long_paths_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + long_paths_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_noop_when_long_paths_enabled( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=True + ), + patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + get_path_mock.assert_not_called() + assert not caplog.records + + +def test_check_windows_path_length_short_path_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_SHORT_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert not caplog.records + + +def test_check_windows_path_length_long_path_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch("esphome.espidf.framework.platform.system", return_value="Windows"), + patch( + "esphome.espidf.framework._windows_long_paths_enabled", return_value=False + ), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=_LONG_IDF_PATH, + ), + caplog.at_level(logging.WARNING), + ): + _check_windows_path_length() + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert _LONG_IDF_PATH in message + assert "long path support" in message From a25ac28ae5224e53fdac8df0a0b5a8c603222da6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:22:44 +1000 Subject: [PATCH 275/282] [lsm6ds] Add motion platform for STMicro LSM6DS IMU (#16232) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/lsm6ds/__init__.py | 15 ++ esphome/components/lsm6ds/lsm6ds.cpp | 203 ++++++++++++++++++++ esphome/components/lsm6ds/lsm6ds.h | 111 +++++++++++ esphome/components/lsm6ds/motion.py | 106 ++++++++++ esphome/components/lsm6ds/sensor.py | 39 ++++ tests/components/lsm6ds/common.yaml | 63 ++++++ tests/components/lsm6ds/test.esp32-idf.yaml | 4 + 8 files changed, 542 insertions(+) create mode 100644 esphome/components/lsm6ds/__init__.py create mode 100644 esphome/components/lsm6ds/lsm6ds.cpp create mode 100644 esphome/components/lsm6ds/lsm6ds.h create mode 100644 esphome/components/lsm6ds/motion.py create mode 100644 esphome/components/lsm6ds/sensor.py create mode 100644 tests/components/lsm6ds/common.yaml create mode 100644 tests/components/lsm6ds/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 300ae13cf4..10128c64e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -291,6 +291,7 @@ esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow esphome/components/lps22/* @nagisa +esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita diff --git a/esphome/components/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py new file mode 100644 index 0000000000..b1044276a1 --- /dev/null +++ b/esphome/components/lsm6ds/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_LSM6DS_ID = "lsm6ds_id" +# C++ namespace / class + +lsm6ds_ns = cg.esphome_ns.namespace("lsm6ds") +LSM6DSComponent = lsm6ds_ns.class_( + "LSM6DSComponent", + MotionComponent, + i2c.I2CDevice, +) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp new file mode 100644 index 0000000000..efdd241578 --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -0,0 +1,203 @@ +#include "lsm6ds.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::lsm6ds { + +static const char *const TAG = "lsm6ds"; + +static const struct { + uint8_t who_am_i; + const char *const name; +} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; + +void LSM6DSComponent::setup() { + MotionComponent::setup(); + uint8_t who_am_i = 0; + if (this->read_register(LSM6DS_REG_WHO_AM_I, &who_am_i, 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Failed to read WHO_AM_I — check wiring and I2C address"); + this->mark_failed(); + return; + } + const char *chip_name = nullptr; + for (const auto &chip : CHIP_IDS) { + if (chip.who_am_i == who_am_i) { + chip_name = chip.name; + break; + } + } + if (chip_name == nullptr) { + ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); + this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); + return; + } + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + this->chip_name_ = chip_name; + + // 2. Software reset — clears all registers to defaults + if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Software reset failed")); + return; + } + // Datasheet: reset bit self-clears after boot (typ. 50 µs); + delay(2); + + // 3. Enable auto-increment and block data update (BDU). + // BDU prevents reading a high-byte from one sample and a low-byte from the next. + // IF_INC is set by default after reset but we set it explicitly for clarity. + uint8_t ctrl3 = CTRL3_C_IF_INC | CTRL3_C_BDU; + if (this->write_register(LSM6DS_REG_CTRL3_C, &ctrl3, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Config failed")); + return; + } + + // 4. Configure accelerometer: ODR in bits[7:4], FS in bits[3:2] + // Anti-aliasing filter bandwidth is left at power-on default (bits[1:0] = 00 = ODR/2). + uint8_t ctrl1_xl = (uint8_t) (this->accel_odr_ << 4) | (uint8_t) (this->accel_range_ << 2); + if (this->write_register(LSM6DS_REG_CTRL1_XL, &ctrl1_xl, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure accelerometer")); + return; + } + + // 5. Configure gyroscope: ODR in bits[7:4], FS_G + FS_125 in bits[3:0] + // For ±125 dps: FS_G[2:1]=00 and FS_125(bit1)=1, so gyro_range_ encodes the full nibble. + uint8_t ctrl2_g = (uint8_t) (this->gyro_odr_ << 4) | (uint8_t) (this->gyro_range_); + if (this->write_register(LSM6DS_REG_CTRL2_G, &ctrl2_g, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure gyroscope")); + return; + } + + // 6. Ensure accelerometer is in high-performance mode (CTRL6_C bit 4 = XL_HM_MODE = 0) + // and gyroscope is in high-performance mode (CTRL7_G bit 7 = G_HM_MODE = 0). + // Both default to 0 (high-performance) after reset, but write explicitly. + uint8_t zero = 0x00; + if (this->write_register(LSM6DS_REG_CTRL6_C, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + if (this->write_register(LSM6DS_REG_CTRL7_G, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } +} + +void LSM6DSComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "LSM6DS IMU:\n" + " Chip type: %s\n", + this->chip_name_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + // Accel range — index into the sensitivity table (datasheet Table 3) + static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; + + const char *gyro_str; + switch (this->gyro_range_) { + case LSM6DS_GYRO_RANGE_125: + gyro_str = "±125dps"; + break; + case LSM6DS_GYRO_RANGE_250: + gyro_str = "±250dps"; + break; + case LSM6DS_GYRO_RANGE_500: + gyro_str = "±500dps"; + break; + case LSM6DS_GYRO_RANGE_1000: + gyro_str = "±1000dps"; + break; + case LSM6DS_GYRO_RANGE_2000: + gyro_str = "±2000dps"; + break; + default: + gyro_str = "unknown"; + break; + } + auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); + auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); + ESP_LOGCONFIG(TAG, + " Accel range : %s\n" + " Accel data rate : %dHz\n" + " Gyro range : %s\n" + " Gyro data rate : %dHz", + ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); +} + +// update_data() +// Called by MotionComponent::update() on each polling interval. +// Reads gyro XYZ and accel XYZ in a single 12-byte burst (registers 0x22–0x2D). +// Values are in g (accel) and °/s (gyro) — MotionComponent handles axis mapping +// and sensor publishing. + +bool LSM6DSComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Single burst: gyro X/Y/Z (0x22–0x27) then accel X/Y/Z (0x28–0x2D) + uint8_t raw[LSM6DS_BURST_LEN]; + if (!this->read_bytes(LSM6DS_REG_OUTX_L_G, raw, LSM6DS_BURST_LEN)) { + this->status_set_error(LOG_STR("Failed to read IMU data")); + return false; + } + this->status_clear_error(); + + // Gyroscope + // Sensitivity (mdps/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mdps → dps (°/s). + static constexpr float GYRO_SCALE[] = { + 8.75e-3f, // 0x00 — ±250 dps + 8.75e-3f, // 0x01 — unused (maps to 250 as fallback) + 4.375e-3f, // 0x02 — ±125 dps (FS_125 bit set) + 8.75e-3f, // 0x03 — unused + 17.50e-3f, // 0x04 — ±500 dps + 17.50e-3f, // 0x05 — unused + 8.75e-3f, // 0x06 — unused + 8.75e-3f, // 0x07 — unused + 35.0e-3f, // 0x08 — ±1000 dps + 35.0e-3f, // 0x09 — unused + 17.50e-3f, // 0x0A — unused + 17.50e-3f, // 0x0B — unused + 70.0e-3f, // 0x0C — ±2000 dps + }; + float gyro_scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + + // Accelerometer + // Sensitivity (mg/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mg → g. + // Note: FS_XL register values are non-monotonic (0=2g, 1=16g, 2=4g, 3=8g). + static constexpr float ACCEL_SCALE[] = { + 0.061e-3f, // 0x00 — ±2g + 0.488e-3f, // 0x01 — ±16g + 0.122e-3f, // 0x02 — ±4g + 0.244e-3f, // 0x03 — ±8g + }; + float accel_scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; + data.acceleration[motion::Y_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + + // Temperature (lazy — only read if a listener is registered) + // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when + // temperature is not needed. + // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + if (!this->temperature_callback_.empty()) { + uint8_t raw_t[2]; + if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { + int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + float temperature = (temp_raw / 256.0f) + 25.0f; + this->temperature_callback_.call(temperature); + } + } + + return true; +} + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h new file mode 100644 index 0000000000..75462ff1fb --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -0,0 +1,111 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/motion/motion_component.h" + +namespace esphome::lsm6ds { + +// ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── +static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB + +// Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) +static const uint8_t LSM6DS_BURST_LEN = 12; +static const uint8_t LSM6DS_ACCEL_OFFSET = 6; // 0x28 - 0x22 + +// ── CTRL3_C bit fields ─────────────────────────────────────────────────────── +static const uint8_t CTRL3_C_SW_RESET = (1 << 0); +static const uint8_t CTRL3_C_IF_INC = (1 << 2); // auto-increment address on burst (default 1) +static const uint8_t CTRL3_C_BDU = (1 << 6); // block data update + +// ── Accelerometer full-scale range ────────────────────────────────────────── +// CTRL1_XL bits [3:2] — FS_XL[1:0] +// Note: 0x01 = ±16g is intentional per Table 52 — the mapping is non-monotonic +enum LSM6DSAccelRange : uint8_t { + LSM6DS_ACCEL_RANGE_2G = 0x00, // ±2 g, 0.061 mg/LSB + LSM6DS_ACCEL_RANGE_16G = 0x01, // ±16 g, 0.488 mg/LSB + LSM6DS_ACCEL_RANGE_4G = 0x02, // ±4 g, 0.122 mg/LSB + LSM6DS_ACCEL_RANGE_8G = 0x03, // ±8 g, 0.244 mg/LSB +}; + +// ── Accelerometer output data rate ────────────────────────────────────────── +// CTRL1_XL bits [7:4] — ODR_XL[3:0] +enum LSM6DSAccelODR : uint8_t { + LSM6DS_ACCEL_ODR_OFF = 0x00, + LSM6DS_ACCEL_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_ACCEL_ODR_26 = 0x02, // 26 Hz + LSM6DS_ACCEL_ODR_52 = 0x03, // 52 Hz + LSM6DS_ACCEL_ODR_104 = 0x04, // 104 Hz + LSM6DS_ACCEL_ODR_208 = 0x05, // 208 Hz + LSM6DS_ACCEL_ODR_416 = 0x06, // 416 Hz + LSM6DS_ACCEL_ODR_833 = 0x07, // 833 Hz + LSM6DS_ACCEL_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_ACCEL_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_ACCEL_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Gyroscope full-scale range ─────────────────────────────────────────────── +// CTRL2_G bits [3:0] — FS_G[2:1] and FS_125 (bit 1) +// The FS_125 bit (bit 1) enables the ±125 dps range independently of FS_G. +// For all other ranges, bits [3:2] select the range and bit 1 = 0. +enum LSM6DSGyroRange : uint8_t { + LSM6DS_GYRO_RANGE_125 = 0x02, // ±125 dps, 4.375 mdps/LSB (FS_125=1) + LSM6DS_GYRO_RANGE_250 = 0x00, // ±250 dps, 8.75 mdps/LSB + LSM6DS_GYRO_RANGE_500 = 0x04, // ±500 dps, 17.50 mdps/LSB + LSM6DS_GYRO_RANGE_1000 = 0x08, // ±1000 dps, 35 mdps/LSB + LSM6DS_GYRO_RANGE_2000 = 0x0C, // ±2000 dps, 70 mdps/LSB +}; + +// ── Gyroscope output data rate ─────────────────────────────────────────────── +// CTRL2_G bits [7:4] — ODR_G[3:0] +enum LSM6DSGyroODR : uint8_t { + LSM6DS_GYRO_ODR_OFF = 0x00, + LSM6DS_GYRO_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_GYRO_ODR_26 = 0x02, // 26 Hz + LSM6DS_GYRO_ODR_52 = 0x03, // 52 Hz + LSM6DS_GYRO_ODR_104 = 0x04, // 104 Hz + LSM6DS_GYRO_ODR_208 = 0x05, // 208 Hz + LSM6DS_GYRO_ODR_416 = 0x06, // 416 Hz + LSM6DS_GYRO_ODR_833 = 0x07, // 833 Hz + LSM6DS_GYRO_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_GYRO_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_GYRO_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Main component class ───────────────────────────────────────────────────── +class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters (called from Python codegen) + void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } + void set_accel_odr(LSM6DSAccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(LSM6DSGyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(LSM6DSGyroODR o) { this->gyro_odr_ = o; } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + const char *chip_name_{"Unknown"}; + bool update_data(motion::MotionData &data) override; + + LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; + LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; + LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; + LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py new file mode 100644 index 0000000000..8c2c5198ea --- /dev/null +++ b/esphome/components/lsm6ds/motion.py @@ -0,0 +1,106 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import LSM6DSComponent, lsm6ds_ns + +# ── Dependency declarations ────────────────────────────────────────────────── +DEPENDENCIES = ["i2c"] +DOMAIN = "lsm6ds" + +# ── C++ namespace / class ──────────────────────────────────────────────────── +# ── Enum proxies ───────────────────────────────────────────────────────────── +LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, + "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, + "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, + "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, +} + +LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") +ACCEL_ODR_OPTIONS = { + "OFF": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_OFF, + "12_5HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_12_5, + "26HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_26, + "52HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_52, + "104HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_104, + "208HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_208, + "416HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_416, + "833HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_833, + "1666HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_1666, + "3332HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_3332, + "6664HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_6664, +} + +LSM6DSGyroRange = lsm6ds_ns.enum("LSM6DSGyroRange") +GYRO_RANGE_OPTIONS = { + "125DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_125, + "250DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_250, + "500DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_500, + "1000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_1000, + "2000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_2000, +} + +LSM6DSGyroODR = lsm6ds_ns.enum("LSM6DSGyroODR") +GYRO_ODR_OPTIONS = { + "OFF": LSM6DSGyroODR.LSM6DS_GYRO_ODR_OFF, + "12_5HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_12_5, + "26HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_26, + "52HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_52, + "104HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_104, + "208HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_208, + "416HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_416, + "833HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_833, + "1666HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_1666, + "3332HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_3332, + "6664HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_6664, +} + +# ── CONFIG_SCHEMA ───────────────────────────────────────────────────────────── +# Extend the motion platform schema which provides: +# - accel_x/y/z sensor schemas +# - gyro_x/y/z sensor schemas +# - axis_mapping schema + validation +# - update_interval / polling +CONFIG_SCHEMA = ( + motion_schema(LSM6DSComponent, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="104HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="208HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6A)) +) + + +# ── Code generation ────────────────────────────────────────────────────────── +async def to_code(config): + var = await new_motion_component(config) + + # Let the motion platform handle sensor wiring, axis mapping, and polling + await i2c.register_i2c_device(var, config) + + # Chip-specific hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/lsm6ds/sensor.py b/esphome/components/lsm6ds/sensor.py new file mode 100644 index 0000000000..980e84a2e9 --- /dev/null +++ b/esphome/components/lsm6ds/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_LSM6DS_ID, LSM6DSComponent + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): CONF_TEMPERATURE, + cv.GenerateID(CONF_LSM6DS_ID): cv.use_id(LSM6DSComponent), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LSM6DS_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml new file mode 100644 index 0000000000..832254781f --- /dev/null +++ b/tests/components/lsm6ds/common.yaml @@ -0,0 +1,63 @@ +sensor: + - platform: lsm6ds + name: "lsm6ds Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: lsm6ds + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + accelerometer_odr: 104HZ + + gyroscope_range: 2000DPS + + gyroscope_odr: 208HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/lsm6ds/test.esp32-idf.yaml b/tests/components/lsm6ds/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/lsm6ds/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From dafc3560ddb897798050cabf5d7baf9e2be3fa71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:44:58 +1000 Subject: [PATCH 276/282] [tests] Isolate ESPHOME_LOG_STATES in main logs-states tests (#16905) Co-authored-by: Claude Opus 4.8 --- tests/unit_tests/test_main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e99a630e83..03c005dc27 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6118,6 +6118,15 @@ def test_should_subscribe_states_env_suppresses() -> None: assert _should_subscribe_states(args) is False +def test_should_subscribe_states_env_enables() -> None: + """Test that ESPHOME_LOG_STATES=true enables states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is True + + def test_should_subscribe_states_flag_overrides_env() -> None: """Test that --states overrides ESPHOME_LOG_STATES=false.""" from esphome.__main__ import _should_subscribe_states @@ -6202,7 +6211,11 @@ def test_command_run_defaults_subscribe_states_true( ), patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), patch("esphome.__main__.get_serial_ports", return_value=[]), + patch.dict(os.environ, {}, clear=False), ): + # Ensure the default behavior is not affected by an ambient + # ESPHOME_LOG_STATES set in the test runner's environment. + os.environ.pop("ESPHOME_LOG_STATES", None) result = command_run(args, CORE.config) assert result == 0 From 29a79b1373be6a0969efb5da0eb330e8407f178b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:25:03 -0400 Subject: [PATCH 277/282] [core] Make set_cpp_standard work on the native IDF toolchain (#16907) --- esphome/build_gen/espidf.py | 22 ++++++-- esphome/build_gen/platformio.py | 15 ++++++ esphome/core/__init__.py | 3 ++ esphome/cpp_generator.py | 11 +--- tests/unit_tests/build_gen/test_espidf.py | 50 +++++++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 40 +++++++++++++++ 6 files changed, 129 insertions(+), 12 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9cc7a7ff12..9e11d785c0 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -8,6 +8,17 @@ import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" + def get_available_components() -> list[str] | None: """Get list of built-in ESP-IDF components from project_description.json. @@ -84,6 +95,12 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + cpp_standard_options = ( + CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) + if CORE.cpp_standard + else "" + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -140,6 +157,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + {extra_compile_options} {managed_components_property} @@ -200,9 +219,6 @@ idf_component_register( REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 16c1597ccd..a583279ea7 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 90c162fedd..4289cdf3e5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -593,6 +593,8 @@ class EsphomeCore: self.build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() + # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() + self.cpp_standard: str | None = None # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply @@ -649,6 +651,7 @@ class EsphomeCore: self.platformio_libraries = {} self.build_flags = set() self.build_unflags = set() + self.cpp_standard = None self.defines = set() self.platformio_options = {} self.loaded_integrations = set() diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 151018baa4..582b8fc74d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -705,15 +705,8 @@ def add_build_unflag(build_unflag: str) -> None: def set_cpp_standard(standard: str) -> None: - """Set C++ standard with compiler flag `-std={standard}`.""" - CORE.add_build_unflag("-std=gnu++11") - CORE.add_build_unflag("-std=gnu++14") - CORE.add_build_unflag("-std=gnu++17") - CORE.add_build_unflag("-std=gnu++23") - CORE.add_build_unflag("-std=gnu++2a") - CORE.add_build_unflag("-std=gnu++2b") - CORE.add_build_unflag("-std=gnu++2c") - CORE.add_build_flag(f"-std={standard}") + """Set the C++ language standard for the build (e.g. ``gnu++20``).""" + CORE.cpp_standard = standard def add_define(name: str, value: SafeExpType = None): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 540dd06731..a5c2719f42 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -162,3 +162,53 @@ def test_get_project_cmakelists_emits_managed_components_property( "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" " espressif__esp-dsp APPEND)" ) in content + + +def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None: + """cg.set_cpp_standard() replaces the IDF default -std in + CXX_COMPILE_OPTIONS between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", "gnu++20"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert ( + "idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)" + in content + ) + assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content + assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content + # The replacement must come after project.cmake (which appends the IDF + # default) and before project() (which consumes the options). + include_pos = content.index("tools/cmake/project.cmake") + replace_pos = content.index("CXX_COMPILE_OPTIONS") + project_pos = content.index("project(test)") + assert include_pos < replace_pos < project_pos + + +def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "CXX_COMPILE_OPTIONS" not in content + + +def test_get_component_cmakelists_no_compile_features() -> None: + """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the + top-level CMakeLists; the src component must not set its own.""" + with patch.object(CORE, "build_flags", set()): + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + + assert "target_compile_features" not in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index da0010afa3..2ae3836a25 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] + + +@pytest.fixture +def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CORE, "name", "test") + monkeypatch.setattr(CORE, "platformio_options", {}) + monkeypatch.setattr(CORE, "platformio_libraries", {}) + monkeypatch.setattr(CORE, "build_flags", set()) + monkeypatch.setattr(CORE, "build_unflags", set()) + + +def test_get_ini_content_pins_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """cg.set_cpp_standard() pins -std via build_flags and unflags every other + known standard so the platform/framework default is stripped.""" + monkeypatch.setattr(CORE, "cpp_standard", "gnu++20") + + content = platformio.get_ini_content() + + flags_section = content.split("build_flags =")[1].split("build_unflags =")[0] + unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0] + assert "-std=gnu++20\n" in flags_section + # Both the GNU and strict dialects of every other standard are stripped. + for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"): + assert f"-std=gnu++{year}\n" in unflags_section + assert f"-std=c++{year}\n" in unflags_section + assert "-std=c++20\n" in unflags_section + # The selected standard must not unflag itself. + assert "-std=gnu++20\n" not in unflags_section + + +def test_get_ini_content_no_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(CORE, "cpp_standard", None) + + content = platformio.get_ini_content() + + assert "-std=" not in content From cd7e54dbf23b91dfec42bbbb1c6b3207d8c22770 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:32:17 -0400 Subject: [PATCH 278/282] Bump cryptography from 48.0.0 to 48.0.1 (#16909) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 62ed506e36..a825cd9bff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.0 +cryptography==48.0.1 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 77009cfafe06b4db8b017116b75a9a9dec118611 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 10 Jun 2026 18:21:04 -0400 Subject: [PATCH 279/282] [resampler] Allow resampler to passthrough bits per sample instead of converting (#16892) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/resampler/speaker/__init__.py | 21 ++++++++++-- .../resampler/speaker/resampler_speaker.cpp | 32 ++++++++++++++----- .../resampler/speaker/resampler_speaker.h | 25 +++++++++------ tests/components/resampler/common.yaml | 5 +++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8a13110631..ea080adc6b 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -24,6 +24,8 @@ ResamplerSpeaker = resampler_ns.class_( CONF_TAPS = "taps" +PASSTHROUGH = "passthrough" + def _set_stream_limits(config): audio.set_stream_limits( @@ -35,14 +37,21 @@ def _set_stream_limits(config): def _validate_audio_compatibility(config): - inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + # In passthrough mode the output bits per sample is determined at runtime from the input stream, so there is + # nothing to inherit or validate against the output speaker. + passthrough = config.get(CONF_BITS_PER_SAMPLE) == PASSTHROUGH + if not passthrough: + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( "source_speaker", audio_device=CONF_OUTPUT_SPEAKER, - bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + bits_per_sample=cv.UNDEFINED + if passthrough + else config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), sample_rate=config.get(CONF_SAMPLE_RATE), )(config) @@ -60,6 +69,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ResamplerSpeaker), cv.Required(CONF_OUTPUT_SPEAKER): cv.use_id(speaker.Speaker), + cv.Optional(CONF_BITS_PER_SAMPLE, default=PASSTHROUGH): cv.Any( + cv.one_of(PASSTHROUGH, lower=True), cv.int_range(8, 32) + ), cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, @@ -90,7 +102,10 @@ async def to_code(config): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() - cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + if config[CONF_BITS_PER_SAMPLE] == PASSTHROUGH: + cg.add(var.set_passthrough_bits_per_sample(True)) + else: + cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) cg.add(var.set_filters(config[CONF_FILTERS])) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index ecbd445a80..f1ebd180cc 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -40,11 +40,19 @@ enum ResamplingEventGroupBits : uint32_t { }; void ResamplerSpeaker::dump_config() { - ESP_LOGCONFIG(TAG, - "Resampler Speaker:\n" - " Target Bits Per Sample: %u\n" - " Target Sample Rate: %" PRIu32 " Hz", - this->target_bits_per_sample_, this->target_sample_rate_); + if (this->passthrough_bits_per_sample_) { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: passthrough\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_sample_rate_); + } else { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: %" PRIu8 "\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_bits_per_sample_, this->target_sample_rate_); + } } void ResamplerSpeaker::setup() { @@ -253,8 +261,12 @@ void ResamplerSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { void ResamplerSpeaker::start() { this->send_command_(ResamplingEventGroupBits::COMMAND_START, true); } esp_err_t ResamplerSpeaker::start_() { - this->target_stream_info_ = audio::AudioStreamInfo( - this->target_bits_per_sample_, this->audio_stream_info_.get_channels(), this->target_sample_rate_); + // In passthrough mode, the output keeps the input's bits per sample so only the sample rate is resampled. + const uint8_t target_bits_per_sample = this->passthrough_bits_per_sample_ + ? this->audio_stream_info_.get_bits_per_sample() + : this->target_bits_per_sample_; + this->target_stream_info_ = audio::AudioStreamInfo(target_bits_per_sample, this->audio_stream_info_.get_channels(), + this->target_sample_rate_); this->output_speaker_->set_audio_stream_info(this->target_stream_info_); this->output_speaker_->start(); @@ -305,7 +317,11 @@ void ResamplerSpeaker::set_volume(float volume) { } bool ResamplerSpeaker::requires_resampling_() const { - return (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) || + if (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) { + return true; + } + // In passthrough mode the bits per sample always matches the input, so it never forces resampling. + return !this->passthrough_bits_per_sample_ && (this->audio_stream_info_.get_bits_per_sample() != this->target_bits_per_sample_); } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 4a091e298a..f482ce4b88 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -49,6 +49,12 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { } void set_target_sample_rate(uint32_t target_sample_rate) { this->target_sample_rate_ = target_sample_rate; } + /// @brief When enabled, the input bits per sample are passed through to the output speaker unchanged instead of being + /// converted to a fixed target. Only the sample rate is resampled if it differs from the target. + void set_passthrough_bits_per_sample(bool passthrough_bits_per_sample) { + this->passthrough_bits_per_sample_ = passthrough_bits_per_sample; + } + void set_filters(uint16_t filters) { this->filters_ = filters; } void set_taps(uint16_t taps) { this->taps_ = taps; } @@ -80,23 +86,24 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { speaker::Speaker *output_speaker_{nullptr}; - bool task_stack_in_psram_{false}; - bool waiting_for_output_{false}; - StaticTask task_; audio::AudioStreamInfo target_stream_info_; - uint16_t taps_; - uint16_t filters_; - - uint8_t target_bits_per_sample_; - uint32_t target_sample_rate_; + uint64_t callback_remainder_{0}; uint32_t buffer_duration_ms_; uint32_t state_start_ms_{0}; + uint32_t target_sample_rate_; - uint64_t callback_remainder_{0}; + uint16_t taps_; + uint16_t filters_; + + uint8_t target_bits_per_sample_{0}; + + bool passthrough_bits_per_sample_{false}; + bool task_stack_in_psram_{false}; + bool waiting_for_output_{false}; }; } // namespace esphome::resampler diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 782dc831c4..65dd5590ee 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -7,3 +7,8 @@ speaker: - platform: resampler id: resampler_speaker_id output_speaker: resampler_i2s_speaker_id + bits_per_sample: 16 + - platform: resampler + id: resampler_speaker_2_id + output_speaker: resampler_speaker_id + bits_per_sample: passthrough From 92c82f3d25596a9bfb51cd91a53ccf3d1d1820c7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 10 Jun 2026 17:26:50 -0500 Subject: [PATCH 280/282] [improv_serial] Report stopped state when Wi-Fi is disabled (#16904) Co-authored-by: Claude Opus 4.8 (1M context) --- .../esp32_improv/esp32_improv_component.cpp | 8 +++++++ .../improv_serial/improv_serial_component.cpp | 23 ++++++++++++++++++- .../improv_serial/improv_serial_component.h | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 183820256f..e6fcc018d9 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -338,6 +338,14 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + this->incoming_data_.clear(); + return; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 206df2c844..4ee703f363 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -22,7 +22,9 @@ void ImprovSerialComponent::setup() { if (wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else { + } else if (!wifi::global_wifi_component->is_disabled()) { + // Respect Wi-Fi's disabled state; forcing a scan while disabled throws + // the wifi component into an invalid state from which it cannot recover. wifi::global_wifi_component->start_scanning(); } } @@ -230,6 +232,13 @@ bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) { switch (command.command) { case improv::WIFI_SETTINGS: { + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + return true; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -245,6 +254,14 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } case improv::GET_CURRENT_STATE: + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled; report the Improv "stopped" state so a client can tell + // the user that provisioning is unavailable. Reported transiently without + // disturbing our internal provisioning state machine, so a later `wifi.enable` + // still reports the correct state. + this->send_current_state_(improv::STATE_STOPPED); + return true; + } this->set_state_(this->state_); if (this->state_ == improv::STATE_PROVISIONED) { std::vector url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE); @@ -299,6 +316,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; + this->send_current_state_(state); +} + +void ImprovSerialComponent::send_current_state_(improv::State state) { this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; this->tx_header_[TX_DATA_IDX] = state; this->write_data_(); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index c58c42f0d8..70f9214e2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -57,6 +57,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { bool parse_improv_payload_(improv::ImprovCommand &command); void set_state_(improv::State state); + void send_current_state_(improv::State state); void set_error_(improv::Error error); void send_response_(std::vector &response); void on_wifi_connect_timeout_(); From 4dbc5ce920e50b37ac1e301e338c15ed8cb90f12 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 281/282] Bump version to 2026.6.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996..647d25559a 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.6.0-dev +PROJECT_NUMBER = 2026.6.0b1 # 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 22351244bd..9a951c1527 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.6.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From abf6212a5a3b28c57a9a8f933247fa86b268a1b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 282/282] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9defcacac 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: