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 001/219] [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 bfcb0ca7f8e..6574037efb8 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 2b25cf243d7..2de67542b24 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 00000000000..0b2fd9e62b1 --- /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 2818b8c93e9..3bb14a05a7d 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 c0e7b2886c5..55bb2a1c893 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 53326e94722..008081f5865 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 7086e462a75..2a56551255b 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 349bffe6fbd..77f3d797c5c 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 f645ede7549..35883eed5be 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 7d37edad73b..19773a0e93c 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 fee6377965e..a514a55d80d 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 48f8d00a662..84bc26e0649 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 3e1baf34bad..ccfc04f674b 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 2aa3ae215c4..c7162c139a9 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 0229bc14fa5..f536467e2f0 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 00000000000..61889b0361b --- /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 00000000000..61889b0361b --- /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 00000000000..61889b0361b --- /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 002/219] [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 dd97c6eee9a..03f12c75d73 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 7be3c83a9a5..c5225a7a141 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 92cbb7348a4..f4fd2052852 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 7b94a26f544..703463bee91 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 38df282fb98..dd10a32fd6d 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 4ba1ab5d4d0..48b67e1ef98 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 97d6639c1f7..f5203efe9c6 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 bead63ca215..5f59254aee3 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 331c2f84b05..b2251d00d80 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 caab47dcc24..c49220a0443 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 04f58814659..3df12f39857 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 39ce7c0963a..b597b4b42ee 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 53bb339a8e3..f404f02f003 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 a4f4f46097d..e4b9abb976d 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 d2f30ea3d78..a2923778356 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 1d86d5c71ca..fc666056941 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 52f7ee317e4..eaf3a1f1a7c 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 d91936952ee..cf098f92c9b 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 df7ad4a28c9..a952ecff166 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 f91a7e8f996..e34cffb8e2c 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 a2cee6e8883..1e7dfc17927 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 b31a19392c0..0567164805e 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 2187d138146..4ee3bba4444 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 003/219] [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 03f12c75d73..000087063ff 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 4e7dcc82e5c..b7719c80d13 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 2f09fdc1056..0ef6d212fe5 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 28f72ab831a..bfe1fb01364 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 2c34cbfb07b..74d9a5047ab 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 f6b6d0b05fa..26b550669fa 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 d6fb5b81f22..6be090b869c 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 004/219] [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 703463bee91..ac0d2eaba22 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 005/219] [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 744ce35ef6b..c4a612753b4 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 006/219] [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 ec9dc102970..2f4ef5c948d 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 007/219] [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 41beb6e4e94..fc35271017a 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 7eba7956015..27ae32cc46d 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 008/219] [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 fc49f030676..d6df5595713 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 009/219] [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 4ec2551804c..2c529dcd0fb 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 19f52349a66..3b39d519c4a 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 72013e411eb..08def885774 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 e02b450bf0e..2938fd923c9 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 2139a2b796d..4adc53cae97 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 010/219] [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 3bb14a05a7d..b662293ab5c 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 011/219] [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 390b26ba1d4..f14a920c24b 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 012/219] 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 14dddbb1aaa..17b618dde7e 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 013/219] 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 0470a948f59..a0761289758 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 aad1da08076..203cd2bbea8 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 014/219] [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 1eb33454910..112dde7c450 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 015/219] [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 ac0d2eaba22..4e3ffdc1e40 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 2c73fe7d08d..6062631d984 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 d0d00723fcb..322054ea912 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 36dd44155ae..2ff39cab696 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 00000000000..5ab1b7c0740 --- /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 00000000000..86a4d558589 --- /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 00000000000..6a19e3fee4b --- /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 5f160352cc2..e4921ae1965 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 f536467e2f0..765c1aa3b24 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 f0f96e9adcc..e0fcbab0ee5 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 016/219] [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 5b7b6c7ee63..bfb629143d3 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 017/219] [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 a0c59a517a7..7d4cdc881eb 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 4e3ffdc1e40..beb41b30f4d 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 9883a0a43e0..763a1f34051 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 018/219] [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 83afeac50a7..aed0105e1fb 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 019/219] [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 6125c4ecc95..8214fcf80cb 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 3df12f39857..ba576fcfd77 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 b5b35b51722..ff150f25408 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 b3f8a05605d..105d78505fa 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 020/219] 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 53516db9138..63efff1b3af 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 021/219] [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 beb41b30f4d..d2dc9799660 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 b2251d00d80..6ef73a21996 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 022/219] [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 3f88f8ef9a4..22f5eb33e1f 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 5af25fc3510..9476b38b72a 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 023/219] [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 ef7cbf5ac4a..84f2f8101a1 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 d6df5595713..6f137fb351e 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 024/219] [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 000087063ff..cc179ebf985 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 00000000000..6fce8a55d9e --- /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 752f582e740..2fef3faf8de 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 00000000000..849ef274ed7 --- /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 adc8bfce63a..d00d8662f5f 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 025/219] [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 6f418b48ea0..3f7777883ea 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 202df21ab56..8e85cce75a5 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 97538e13c9b..ddf7fa161bd 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 109db65b634..00000000000 --- 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 c335dee1f36..00000000000 --- 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 026/219] [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 fdbd70bc61d..07cb2ac2436 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 0437267a1f0..d0521e548a1 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 11b39b50003..b395c771414 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 00000000000..229a24b2d18 --- /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 027/219] [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 22f5eb33e1f..784f5dee8cc 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 00000000000..c16abc5bda8 --- /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 17c84ee9543..7d06377f904 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 6481c8c1f4a..544ec79c327 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 ef7bd463328..250297ddb5c 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 00000000000..904a9167896 --- /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 028/219] [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 c880e036cbf..2b1458e2aee 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 029/219] [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 cc179ebf985..47dd8d273cd 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 26b550669fa..8cce60d3512 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 d7f809181a43878b0e8e100e0edb5610d9535906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 10:20:31 -0700 Subject: [PATCH 030/219] [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 72c2c355dcc..ad3877465d2 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 3f57117efddb699089f64a762c98b103b9baf89c 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 031/219] [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 4f77258b2ca..1a95f77437f 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 @@ -2657,13 +2657,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 a04f6da814e318e9fecf766232edb4fc426619cb 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 032/219] [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 06a64208b6e..f3e0e0db8f1 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 0106f248451..c724ea2875a 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 @@ -93,6 +94,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 eab6bfc2cb4..690c47c1832 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 4783112578c..4ff857951fa 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -838,3 +838,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 f9aba18f8e992581bf9c70a9f24a3c9d81c56110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 May 2026 02:57:44 -0500 Subject: [PATCH 033/219] [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 9c512504b72..01a7b5450b2 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 3a8a4787ed2..dfeaaa57d1a 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 8e57894af709aa174971ac05d2d5f598e00db94a Mon Sep 17 00:00:00 2001 From: Fyleo Date: Sat, 30 May 2026 05:03:42 +0200 Subject: [PATCH 034/219] [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 83afeac50a7..aed0105e1fb 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 a4d247fa0a47d935cc006b0748ebe7acc170c4eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH 035/219] [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 5a98b947819..e4298b0865a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -711,6 +711,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 7f8885ba5ff..dc1576ab187 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 4ce862315d7..39cd042a960 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 ea37492cf42..2a6f22abb1c 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 571a12ffe5dbfd8967802b9352bf3cc3b02e07fa 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 036/219] [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 ad3877465d2..192c9d68e8d 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 91b4bd8e87b..be37dd5d584 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -75,6 +75,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 @@ -106,6 +107,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: @@ -443,6 +458,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() @@ -463,11 +483,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() @@ -492,6 +515,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() @@ -501,6 +525,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 559cfd1555f4af48687b3898b2fc281bd0b33942 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:32:23 -0500 Subject: [PATCH 037/219] [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 f2bf3752fab..cd5b3fd694b 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 070c14b04a10d986ee4aab16403c4a08be7b9ac9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:33:41 +1200 Subject: [PATCH 038/219] Bump version to 2026.5.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 30ae42ea2c7..3dfe6c5ed4c 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.1 +PROJECT_NUMBER = 2026.5.2 # 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 39c5c6b60e5..fdbbbe5eabe 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.1" +__version__ = "2026.5.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 1740e541053b007fd47d1504a42dfe1443ad055a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 1 Jun 2026 20:20:18 -0700 Subject: [PATCH 039/219] [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 410c1a53c07..81bb77843d1 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 00000000000..401b376db6b --- /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 00000000000..02d69ca95ea --- /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 00000000000..6713fcc4542 --- /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 040/219] [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 951b8c04983..8e432695a14 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 8215d8b518b..5ba2f4b1a51 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 ffe901504d3..0afb67fb368 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 041/219] [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 1684f479ba3..d9dd6d5ee2d 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 00000000000..17279fda501 --- /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 00000000000..8e3aff79774 --- /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 00000000000..6ae617e230a --- /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 00000000000..00bad206d82 --- /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 042/219] [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 744b5d16c49..7a66da11f2b 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 6e005f897e5..022d629960b 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 026c2145692..3554e322991 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 4952bda95f0..b38ddad4914 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 364ada90463..3c5a84594eb 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 ef3f12cb735..befb0196127 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 00000000000..9a767c0dae5 --- /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 c11c7816e4e..e7f5143d911 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 043/219] [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 47dd8d273cd..7c4028da44e 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 0b50f723826..9cc7a7ff122 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 30dbb69d86f..16c1597ccd7 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 6ef73a21996..2c520d0d2c6 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 84f2f8101a1..b29b3c4b79f 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 a124dbc1284..da0010afa3f 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 6f137fb351e..1487517ca27 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 044/219] [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 52d75d16016..29c8b414f64 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 31ec1ce5b52..05f9a781560 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 2fa51f73af5..9580464a2e8 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 e55db9f9760..3642309c26a 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 292e5a1156e..7d93ee62e15 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 a8f29c7ca86..54b5711923e 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 8a89f96b39a..4ac60d8099a 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 045/219] [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 400f7c709b0..4d62c3de057 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 e2407fad5af..166e88f382a 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 046/219] 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 17b618dde7e..85d9857e7d0 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 047/219] 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 dfc0e08bfa8..122bb30b5e0 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 048/219] 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 6c80d36d20b..e48d6f69bd2 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 675bbe9d2c7..2a5b701248a 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 d9148fb06dc..73c437467b5 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 89fbec54206..2a40675f3b1 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 6713fcc4542..43d530128cc 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 025b9609859..35cfce65f80 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 63efff1b3af..d3fc19ca41d 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 013517bde6d..1bd60fd11d8 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 7cdbfcf3285..5ad0b02de16 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 122bb30b5e0..c71d7204de3 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 e8320672d2b..0e2efb1bcf5 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 344bd416c6b..8efc395951a 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 84be3c8e226..8796ddf7f0c 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 049/219] [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 705c741dd0f..a794e7721f5 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 24453750ae3..1eb812ae467 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 050/219] [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 78c180aa65d..57266f33e2a 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 3f54b705606..a5ebf44c38b 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 66f93915b6f..d3d3c9ee5e5 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 051/219] [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 e4921ae1965..9629dce0bf9 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 8e0c00267a7..b216a5427d6 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 765c1aa3b24..6c840f56ee1 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 00000000000..3bb1691767a --- /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 00000000000..76444a2e89c --- /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 00000000000..a60c1fddd95 --- /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 052/219] [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 c7f79ad84a3..fd136a4e6d9 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 053/219] [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 ba576fcfd77..3bdda1a9a1c 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 b29b3c4b79f..a9c072f1562 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 d00d8662f5f..8849ea8bc89 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 105d78505fa..7ba56b05f42 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 1487517ca27..c8cf68ff3e3 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 054/219] [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 6ec0ee08b88..f98cde7968d 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 055/219] [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 7bf3092a4e8..d45f2d9df25 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 00000000000..e69de29bb2d 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 00000000000..88307d0dcfc --- /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 056/219] [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 7c4028da44e..f7d3f8e834b 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 9da39a387ba..91e6df8bad5 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 8cce60d3512..e99a630e837 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 b5816f742ea..baaa99f2a76 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 057/219] 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 2a5b701248a..c6e9a358ab2 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 d3fc19ca41d..ca1fb07fda2 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 8796ddf7f0c..ab1ce2b5874 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 058/219] 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 03b48038601..66d016b42d8 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 059/219] [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 cf098f92c9b..94a78e8423f 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 9839e766e21..1ebfe405a79 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 767b55c94b5..651268609e1 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 ac3c6424bf1..acc268fa686 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 060/219] [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 ef613b82bcc..dee42ed2808 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 061/219] [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 29c8b414f64..648b31f8f04 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 a0761289758..3b6278e6b5e 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 f4785355673..1a6e4eb7bec 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 062/219] [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 d2dc9799660..160c06534eb 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 050002d9e27..7398a91c36a 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 5f59254aee3..4d06fb842a5 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 4f0a71053d2..602ff039422 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 063/219] [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 e9630f5266b..9c9c7e38711 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 3f7777883ea..ebb4186a2bc 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 7796f5d891d..d589381461f 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 064/219] [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 ca1fb07fda2..9f227b37a86 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 065/219] [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 648b31f8f04..c007df6b9dd 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 00000000000..2cfbe67a708 --- /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 56c0a9db713..ce266e23826 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 1ebfe405a79..8b6751c1d32 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 29964902956..b277ed18d0b 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 066/219] [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 c007df6b9dd..c3604e7ef26 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 c9775ab6012..c051d70f3d9 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 9476b38b72a..4190c800274 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 067/219] [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 ce266e23826..47f59e62a4b 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 068/219] [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 47f59e62a4b..633b8d4b7d5 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 069/219] [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 ed61b619362..a7de48a6ee9 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 4810e8478de..41fea5a255f 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 09121fa9c91..1c44a9a2380 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 b216a5427d6..05fc959ceb2 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 73dd685df69..a49a5e34b34 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 070/219] [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 a455e2fd7f8..e31f72dfb96 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 cd2d2143f58..b24c099bce3 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 5249f4a59e0..098a59937a1 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 4d73ed6dd0f..20924520874 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 94e6d69dcbd..db56bd13f0b 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 2c90ee23f83..1ccf59a2f66 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 bfedbb93194..b0bbfce6490 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 071/219] [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 fc666056941..a343e34328d 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 8b6751c1d32..fc2a3607fbd 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 374a4bce1e3..681ec2aa531 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 cd060c176e6..9ac1056ba49 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 dade44d145b..fa3984d57e0 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 dade44d145b..a1b7b754236 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 dee42ed2808..55e96df4c27 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 6712f1e4686..ba42761635f 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 f1721f08625..f12a9615af7 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 8ff09ed2561..782dc831c4b 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 6712f1e4686..c6bc03e661b 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 f1721f08625..1d80d24cdf6 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 f1239de3cb3..5914e4ca45b 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 241a9a89039..82887749417 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 cc04f5bf795..eceef3a9b5e 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 20e38e8df81..4d89f4cd2ea 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 9c1f32d5bd0..9dfe3b48774 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 67bd6c28ef3..e3972b4da9e 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 9aaf639162b..895f4b4b8f3 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 71c8b06e24d..48c55769da4 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 4712e4bae88..9ef164bb03f 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 27b8604656c..b6aeca4faa6 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 7d663b802c6..d31b97553ec 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 e2439ebdf21..5a2fd16938d 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 0fa09033700..812e7a2314c 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 d09de743964..8604bea795c 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 0cc670a77ef..de2b221da7a 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 76f14b8664a..5e925d00674 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 00000000000..b540ca9af05 --- /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 00000000000..d6632cc2642 --- /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 072/219] [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 cdb9f1f6665..ba3fba6c12d 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 4088a78e0d2..3ae15f9ee5d 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 c7385cbee32..9c1c7331d11 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 cd9b27768e7..2acc080c6d2 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 073/219] 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 203cd2bbea8..9da27acc19a 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 074/219] 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 c71d7204de3..e559472b60c 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 a5b4a7cd514d7dac5bf317c6a9e558e246828bbf 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 075/219] [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 c7f79ad84a3..fd136a4e6d9 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 375ecdfb2c4489300ce281942ee16fad3a0f7bd4 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 076/219] [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 dc1576ab187..65444a2ed87 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 192c9d68e8d..67202ff925a 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 adc8bfce63a..15e22138164 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -56,3 +56,12 @@ 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 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 2a6f22abb1c..5b318008e12 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 be37dd5d584..2e3499e8e3d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -76,6 +76,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 @@ -121,6 +122,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 5662e1b7cddd5ece07dfe8c60b47f2a48555ad30 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 077/219] [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 862d532645a..2ac3c4698b2 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -402,18 +402,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 bcf5606b31630a91bfde8a5608d524fa260113e6 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 078/219] [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 7bf3092a4e8..d45f2d9df25 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 00000000000..e69de29bb2d 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 00000000000..88307d0dcfc --- /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 7f3feec3a38e8df647f15af10e1732c2a1015aba Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:11:36 +1200 Subject: [PATCH 079/219] Bump version to 2026.5.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dfe6c5ed4c..3d74858d3d6 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.2 +PROJECT_NUMBER = 2026.5.3 # 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 fdbbbe5eabe..8bc7907cd02 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.2" +__version__ = "2026.5.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( 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 080/219] [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 c3604e7ef26..1dc63cc7bb3 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 c051d70f3d9..2ddce577ef4 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 4190c800274..9c87a7e5cf4 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 081/219] [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 1cf78fdbd53..7b9c320879e 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 00000000000..d57d7fe3bdf --- /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 fb8425f6cdc..7a19aa8e4b3 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 5869b9468b6..c8c1ee7df24 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 082/219] [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 00000000000..f8afbbde049 --- /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 083/219] [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 031fa342c1b..ddd03ace4ac 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 084/219] [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 3cefc0454a3..567a84f8c36 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 7d8caade740..56c33a708f9 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 5498c385159..592207efa8b 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 480fd86750b..a9f07a5422d 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 4ee8e2ac5e2..45e2be17c77 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 085/219] [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 3554e322991..0939d84aa5b 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 9bd2dded2c8..0ff934ae94a 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 82827d813e8..c99f69989a5 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 4d1d8360998..dfa8a36e1a0 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 00530c3f96e..aacb2179657 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 dac8ac9dbcd..3ffef86f3ea 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 de5e4c13d49..c65a213929f 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 955e945526a..c14abdb4fd3 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 086/219] [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 9f227b37a86..ae3f4e2b98d 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 8e9a2c52985..502f83cd5d4 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 087/219] [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 d5428cc8d79..c5f904ead16 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 261f40c0dbb..67fd03a813f 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 d57d7fe3bdf..c2c8993805b 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 e3bf5e40bc1..3fdf35a4720 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 7a19aa8e4b3..41dc2c546d8 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 088/219] [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 1dc63cc7bb3..cab077385db 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 160c06534eb..6ecb41bff88 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 4ac60d8099a..07e9b8aad31 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 089/219] [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 70fa41b3124..351b0869b08 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 8557427096f..bf14514636c 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 b42fdd2d307..5898492a50e 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 787f2f5de82..cf1288d90c7 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 ade9e165720..1809f181bea 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 03d3286ab82..34b2b827b60 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 090/219] [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 8c8a04c5b71..293d8aa6481 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 091/219] [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 293d8aa6481..bb47e7b0f54 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 26f858b5d21..41759478b88 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 092/219] [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 00000000000..7a17c222a39 --- /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 ae3f4e2b98d..40267240d88 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 093/219] [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 62c57b18cf7..36780a3055b 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 094/219] [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 7dbf33878a4..111d090c3e1 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 095/219] [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 6e4000e06e9..025186299d7 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 096/219] [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 cab077385db..0782b065f35 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 7d4cdc881eb..344248fcf31 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 6ecb41bff88..7e7b1278147 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 763a1f34051..c3b35a8279b 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 a0fadbce8b4..086cdcc2672 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 9c87a7e5cf4..3a5b0500727 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 097/219] [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 18d0b44701d..206df2c8443 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 2f1d0136a44..c58c42f0d83 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 098/219] [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 40267240d88..96c205fb708 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 00000000000..9d498dd64f0 --- /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 a952ecff166..20457e906ab 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 0645333a819..09ef0e1fad8 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 e764f0fe210..a3019466b55 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 e764f0fe210..a3019466b55 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 ff1e3bb9195..f31e0e087d0 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 b77dc299c21..77cf50d17ce 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 e764f0fe210..a3019466b55 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 e764f0fe210..a3019466b55 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 4cc865bb5d3..617464818b0 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 face38b6472..d8992597731 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 4cc865bb5d3..617464818b0 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 4cc865bb5d3..617464818b0 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 39d5739255e..327234d6caa 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 c28e9584dd1..b844f5ae929 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 11a7117d91b..a7937ffca2f 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 2c99e937f39..2cbb254adfa 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 d4fd3becbb9..03e82ab26e3 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 eb599da7735..245b87bec99 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 58820251917..28798f8173f 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 e3fd159b082..4f4cf6ea590 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 c0540225830..667d0be9e7a 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 4ea1dd60f38..4ed6ad7fc93 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 e779f7f078b..3ba3564608a 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 37011b16eec..e0bc185d2cf 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 fe0c5bf9170..0db54d10d09 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 a376004b2fc..cbd056f0700 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 503c4596e92..139571ccecd 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 7fa5ab668c7..bbb6c9182b4 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 01edf0b0b53..0665879b08f 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 49d73b7d3de..bb1868fdef8 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 ba8e0ad0717..27fc930b687 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 a722a5f7c24..6617671972f 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 761d10f16a7..12e4397c491 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 25fe3b67963..4593784ef91 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 1c4d768b087..070b03eeb9a 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 b92b1e13b92..6f5cf61268a 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 3b9b33c048e..f15b609d843 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 ac978482fcd..c548b4f0f4f 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 bdc478ea036..f05735e8f40 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 8b1447a17a0..a7ce7ed2803 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 f6f7c5553b4..19d00627f83 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 c156b4aea19..59063291e79 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 387ea47335d..8ada8b7a4e9 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 11e2a16ca16..edf4493aa6f 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 6cee2af2325..50f8a5bc856 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 ff464cda246..0fc40737f0f 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 aea2b4bbb03..9e93c4c289d 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 2e7bfc5ae52..492b57c4493 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 03a9c42a38d..ce2a13fca74 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 26550d3c5c9..c912fb9b84e 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 51d3e190772..377bad5578b 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 51d3e190772..377bad5578b 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 970c18e0d2a..a7740e771d2 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 2acc080c6d2..71c00e5f103 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 925197182ca..49e49b43187 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 518011e9257..1eb58eabc43 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 cb421ed4bb9..60521b8088c 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 cb421ed4bb9..60521b8088c 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 a5a37fd5596..21d5cad7744 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 18f1ba10aea..5e71736eb00 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 9ba7f348575..08001855cb1 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 93adcf9988b..d99f8ddc4e4 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 6a308b67ddf..e51240f20d9 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 01b83c4ab82..0562a4ba515 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 9915e086eb0..e57c7bec29a 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 67a06cf9c11..f96651e9bf1 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 d79e3ee2ed2..9eadd97a6de 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 190abbc7b19..70a0809883f 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 385fee359e7..f0b44ce8c3c 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 79ae7f601d7..1be2c335bc0 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 c17c2dd5f83..b1a16ebfedd 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 32c909d3512..ee4c1ed0b8e 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 d7722d171a4..fc61aad92ef 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 bbb514bded2..4d2785f3e8c 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 9116fd86e09..9972a673c02 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 f219f71ee25..1f1485fd6c2 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 81d802e9bf5..df20dcde2b0 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 77a77fa3e4f..aeda76d35c9 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 262e75591e6..320e5f775fe 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 7ff416dccbe..951d8f7fc5b 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 109db65b634..0eadfa73924 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 c6c70496059..5631c48f957 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 b3eec495483..8966b574df3 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 9f25efa431a..bd72abbd173 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 65bb248e950..46d80827068 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 b0f44869d3c..4a8e56a255a 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 0013960c107..bb1d73b3bc1 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 b9b1436cdb1..254ac0e13dd 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 45c039106fe..2970a48afbe 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 d3d3c9ee5e5..3be86cf8be0 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 895f4b4b8f3..96f459c53f3 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 be8172af7ee..70c91259bad 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 f099f777295..dbe109f5244 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 09eb569a8e2..b3b8ad85dc9 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 afdf26c150f..3ea235cfb91 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 659550cc01d..a4a24d8da71 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 6e48952fcca..b7eadc084fe 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 d3985a848bf..f1387a7afeb 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 56089aed1e1..1f9249f1baa 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 fa76316b9c5..c8051880054 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 a40ca455cbc..6824c5cca89 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 4260f0ab4cd..2365b7a3687 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 f7865ea87be..478c75ad37a 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 b1885af2665..cfba0060d9a 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 39013baeb23..93bb17b38fc 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 18294974b9e..5ff48bc64c3 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 49f0d1ec6b1..1b2de74c023 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 b402077aa35..a21a4a34d0b 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 863633937bd..55c67efd885 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 0b62a7a009d..ee00da22bbe 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 a754bec5c72..d48063bb4d7 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 969f16bb12f..d17db2f676b 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 00000000000..9286380de1c --- /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 099/219] 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 bb5f52e10c7..b8251e8e018 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 100/219] 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 96c205fb708..40267240d88 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 9d498dd64f0..00000000000 --- 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 20457e906ab..a952ecff166 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 09ef0e1fad8..0645333a819 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 a3019466b55..e764f0fe210 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 a3019466b55..e764f0fe210 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 f31e0e087d0..ff1e3bb9195 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 77cf50d17ce..b77dc299c21 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 a3019466b55..e764f0fe210 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 a3019466b55..e764f0fe210 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 617464818b0..4cc865bb5d3 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 d8992597731..face38b6472 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 617464818b0..4cc865bb5d3 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 617464818b0..4cc865bb5d3 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 327234d6caa..39d5739255e 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 b844f5ae929..c28e9584dd1 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 a7937ffca2f..11a7117d91b 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 2cbb254adfa..2c99e937f39 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 03e82ab26e3..d4fd3becbb9 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 245b87bec99..eb599da7735 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 28798f8173f..58820251917 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 4f4cf6ea590..e3fd159b082 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 667d0be9e7a..c0540225830 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 4ed6ad7fc93..4ea1dd60f38 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 3ba3564608a..e779f7f078b 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 e0bc185d2cf..37011b16eec 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 0db54d10d09..fe0c5bf9170 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 cbd056f0700..a376004b2fc 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 139571ccecd..503c4596e92 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 bbb6c9182b4..7fa5ab668c7 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 0665879b08f..01edf0b0b53 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 bb1868fdef8..49d73b7d3de 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 27fc930b687..ba8e0ad0717 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 6617671972f..a722a5f7c24 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 12e4397c491..761d10f16a7 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 4593784ef91..25fe3b67963 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 070b03eeb9a..1c4d768b087 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 6f5cf61268a..b92b1e13b92 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 f15b609d843..3b9b33c048e 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 c548b4f0f4f..ac978482fcd 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 f05735e8f40..bdc478ea036 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 a7ce7ed2803..8b1447a17a0 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 19d00627f83..f6f7c5553b4 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 59063291e79..c156b4aea19 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 8ada8b7a4e9..387ea47335d 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 edf4493aa6f..11e2a16ca16 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 50f8a5bc856..6cee2af2325 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 0fc40737f0f..ff464cda246 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 9e93c4c289d..aea2b4bbb03 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 492b57c4493..2e7bfc5ae52 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 ce2a13fca74..03a9c42a38d 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 c912fb9b84e..26550d3c5c9 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 377bad5578b..51d3e190772 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 377bad5578b..51d3e190772 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 a7740e771d2..970c18e0d2a 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 71c00e5f103..2acc080c6d2 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 49e49b43187..925197182ca 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 1eb58eabc43..518011e9257 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 60521b8088c..cb421ed4bb9 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 60521b8088c..cb421ed4bb9 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 21d5cad7744..a5a37fd5596 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 5e71736eb00..18f1ba10aea 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 08001855cb1..9ba7f348575 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 d99f8ddc4e4..93adcf9988b 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 e51240f20d9..6a308b67ddf 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 0562a4ba515..01b83c4ab82 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 e57c7bec29a..9915e086eb0 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 f96651e9bf1..67a06cf9c11 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 9eadd97a6de..d79e3ee2ed2 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 70a0809883f..190abbc7b19 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 f0b44ce8c3c..385fee359e7 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 1be2c335bc0..79ae7f601d7 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 b1a16ebfedd..c17c2dd5f83 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 ee4c1ed0b8e..32c909d3512 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 fc61aad92ef..d7722d171a4 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 4d2785f3e8c..bbb514bded2 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 9972a673c02..9116fd86e09 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 1f1485fd6c2..f219f71ee25 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 df20dcde2b0..81d802e9bf5 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 aeda76d35c9..77a77fa3e4f 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 320e5f775fe..262e75591e6 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 951d8f7fc5b..7ff416dccbe 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 0eadfa73924..109db65b634 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 5631c48f957..c6c70496059 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 8966b574df3..b3eec495483 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 bd72abbd173..9f25efa431a 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 46d80827068..65bb248e950 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 4a8e56a255a..b0f44869d3c 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 bb1d73b3bc1..0013960c107 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 254ac0e13dd..b9b1436cdb1 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 2970a48afbe..45c039106fe 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 3be86cf8be0..d3d3c9ee5e5 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 96f459c53f3..895f4b4b8f3 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 70c91259bad..be8172af7ee 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 dbe109f5244..f099f777295 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 b3b8ad85dc9..09eb569a8e2 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 3ea235cfb91..afdf26c150f 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 a4a24d8da71..659550cc01d 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 b7eadc084fe..6e48952fcca 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 f1387a7afeb..d3985a848bf 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 1f9249f1baa..56089aed1e1 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 c8051880054..fa76316b9c5 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 6824c5cca89..a40ca455cbc 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 2365b7a3687..4260f0ab4cd 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 478c75ad37a..f7865ea87be 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 cfba0060d9a..b1885af2665 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 93bb17b38fc..39013baeb23 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 5ff48bc64c3..18294974b9e 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 1b2de74c023..49f0d1ec6b1 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 a21a4a34d0b..b402077aa35 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 55c67efd885..863633937bd 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 ee00da22bbe..0b62a7a009d 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 d48063bb4d7..a754bec5c72 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 d17db2f676b..969f16bb12f 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 9286380de1c..00000000000 --- 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 101/219] [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 0782b065f35..3bcf356f864 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 c944e8a930c..d99dffdc081 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 8e87f67e6d2..f8535eb6286 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 2cfbe67a708..7647db63f56 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 3a5b0500727..4a4bc185799 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 07e9b8aad31..d7bcc49758f 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 633b8d4b7d5..f19bdb9b566 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 00000000000..7a71dc26f42 --- /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 102/219] [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 3bcf356f864..e89b4230ad9 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 40267240d88..a0d604f2485 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 4a4a88fd48f..de3e4fa68e9 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 6c840f56ee1..410858f904d 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 7647db63f56..62d6f0d00d3 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 d7bcc49758f..d3fde193b41 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 1a6e4eb7bec..62f76246b4c 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 b277ed18d0b..8d177a7e26e 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 00000000000..6dd5f4f329a --- /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 00000000000..b49dcf0ef28 --- /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 00000000000..15b97eb1b41 --- /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 e19e7886a27..194926a5df9 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 7a71dc26f42..9791dfc543c 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 103/219] [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 3c3e502058d..abe33f94678 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 00000000000..aea052fa2f1 --- /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 00000000000..8715c8385c9 --- /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 00000000000..00310c16fe3 --- /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 00000000000..ad3163a01a5 --- /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 00000000000..e69de29bb2d diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py new file mode 100644 index 00000000000..f2c0f263442 --- /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 104/219] [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 e0fcbab0ee5..e9fa9446d42 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 105/219] [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 e89b4230ad9..25ae506732f 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 ff12e6157dd..ff38ab1740f 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 bfb629143d3..62833a76493 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 d3fde193b41..182f426a310 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 f19bdb9b566..1416b9b3329 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 8d177a7e26e..2bd702f48e5 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 106/219] 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 658f9e2c4a7..b7c56a283a7 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 ccd43c72cf2..c3b744c919a 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 0939d84aa5b..46e7a7d5a79 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 b38ddad4914..3c33c26726a 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 3c5a84594eb..8c6ffff5005 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 025186299d7..4b0b0ee548c 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 dd1d88e74c8..8b81a57fefe 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 107/219] [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 abe33f94678..6a81cc1d40c 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 aed63c7dfaa..e69de29bb2d 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 c3f3b8f199f..dee26b524aa 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 108/219] [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 a0d604f2485..3115b5b4733 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 a343e34328d..8eb80d9943a 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 00000000000..13da66c9b10 --- /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 a952ecff166..5eeeafac2a5 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 0645333a819..09ef0e1fad8 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 e764f0fe210..a3019466b55 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 e764f0fe210..a3019466b55 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 ff1e3bb9195..f31e0e087d0 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 b77dc299c21..77cf50d17ce 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 e764f0fe210..a3019466b55 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 e764f0fe210..a3019466b55 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 4cc865bb5d3..617464818b0 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 face38b6472..d8992597731 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 4cc865bb5d3..617464818b0 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 4cc865bb5d3..617464818b0 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 39d5739255e..327234d6caa 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 c28e9584dd1..b844f5ae929 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 11a7117d91b..a7937ffca2f 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 2c99e937f39..2cbb254adfa 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 ca86445777c..060254990df 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 e7f55b4806c..02cb3814f29 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 085f853c8e9..1d8d4646fa9 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 d4fd3becbb9..03e82ab26e3 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 eb599da7735..245b87bec99 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 58820251917..28798f8173f 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 e3fd159b082..4f4cf6ea590 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 c0540225830..667d0be9e7a 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 4ea1dd60f38..4ed6ad7fc93 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 e779f7f078b..3ba3564608a 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 9afb39cd315..c217ce9d396 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 37011b16eec..e0bc185d2cf 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 fe0c5bf9170..0db54d10d09 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 a376004b2fc..cbd056f0700 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 3ed96784473..656b1971a5b 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 503c4596e92..139571ccecd 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 7fa5ab668c7..bbb6c9182b4 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 01edf0b0b53..0665879b08f 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 49d73b7d3de..bb1868fdef8 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 ba8e0ad0717..27fc930b687 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 761d10f16a7..12e4397c491 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 25fe3b67963..4593784ef91 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 1c4d768b087..070b03eeb9a 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 b92b1e13b92..6f5cf61268a 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 3b9b33c048e..f15b609d843 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 ac978482fcd..c548b4f0f4f 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 bdc478ea036..f05735e8f40 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 8b1447a17a0..a7ce7ed2803 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 f6f7c5553b4..19d00627f83 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 c156b4aea19..59063291e79 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 387ea47335d..8ada8b7a4e9 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 11e2a16ca16..edf4493aa6f 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 6cee2af2325..50f8a5bc856 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 ff464cda246..0fc40737f0f 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 60e3defd496..71a7ac65c2f 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 aea2b4bbb03..9e93c4c289d 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 2e7bfc5ae52..492b57c4493 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 03a9c42a38d..ce2a13fca74 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 cd2b10d31b6..d9a4a43a260 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 26550d3c5c9..c912fb9b84e 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 51d3e190772..377bad5578b 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 51d3e190772..377bad5578b 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 37033a128e1..7ced9a29b3e 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 bd842454a1b..cebadcbf2cf 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 970c18e0d2a..2287e812bd1 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 2acc080c6d2..71c00e5f103 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 925197182ca..49e49b43187 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 518011e9257..1eb58eabc43 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 cb421ed4bb9..60521b8088c 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 cb421ed4bb9..60521b8088c 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 a5a37fd5596..21d5cad7744 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 18f1ba10aea..5e71736eb00 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 9ba7f348575..08001855cb1 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 9915e086eb0..e57c7bec29a 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 67a06cf9c11..f96651e9bf1 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 6af2ce3939a..a1d27cdbd52 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 13ed311b176..6b0b16e5006 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 070672f15c9..0f5293ac616 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 79ae7f601d7..1be2c335bc0 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 c17c2dd5f83..b1a16ebfedd 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 32c909d3512..ee4c1ed0b8e 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 d7722d171a4..fc61aad92ef 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 bbb514bded2..4d2785f3e8c 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 9116fd86e09..9972a673c02 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 f219f71ee25..1f1485fd6c2 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 81d802e9bf5..df20dcde2b0 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 77a77fa3e4f..aeda76d35c9 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 262e75591e6..320e5f775fe 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 7ff416dccbe..951d8f7fc5b 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 109db65b634..0eadfa73924 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 c6c70496059..5631c48f957 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 b3eec495483..8966b574df3 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 9f25efa431a..fb7b08eeb4d 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 65bb248e950..670f3ef9a47 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 b0f44869d3c..2b1ccae5a77 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 0013960c107..5baecaebb8a 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 b9b1436cdb1..254ac0e13dd 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 45c039106fe..2970a48afbe 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 d3d3c9ee5e5..3be86cf8be0 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 895f4b4b8f3..96f459c53f3 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 d31b97553ec..655e4241f18 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 be8172af7ee..70c91259bad 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 f099f777295..dbe109f5244 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 09eb569a8e2..b3b8ad85dc9 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 afdf26c150f..3ea235cfb91 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 659550cc01d..a4a24d8da71 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 6e48952fcca..b7eadc084fe 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 d3985a848bf..92a1fc8eda4 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 89588f3c76a..f16f07503e6 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 fe7fe25f0ee..e372582ac19 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 56089aed1e1..1f9249f1baa 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 fa76316b9c5..c8051880054 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 a40ca455cbc..6824c5cca89 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 4260f0ab4cd..2365b7a3687 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 f7865ea87be..478c75ad37a 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 b1885af2665..cfba0060d9a 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 39013baeb23..93bb17b38fc 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 18294974b9e..5ff48bc64c3 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 49f0d1ec6b1..1b2de74c023 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 b402077aa35..a21a4a34d0b 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 863633937bd..55c67efd885 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 0b62a7a009d..ee00da22bbe 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 a754bec5c72..d48063bb4d7 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 969f16bb12f..d17db2f676b 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 00000000000..1ac8edeca0b --- /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 00000000000..6ed1bd2c1e8 --- /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 109/219] 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 3115b5b4733..a57be34e9b4 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 110/219] 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 85d9857e7d0..8202a2bb440 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 111/219] [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 25ae506732f..591ca3eb4dd 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 aa3a08c2949..cd1b7d2bb06 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 182f426a310..251339fb5ff 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 112/219] [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 9d19770c57c..0f4189ad90c 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 69c8e5abba2..53f501dee26 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 aee4e424455..8b0b86fe7a5 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 113/219] [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 ee8bd067004..ee46f931de1 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 114/219] [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 6a81cc1d40c..c5beba8c0b9 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 00000000000..81e00ca74ba --- /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 00000000000..0017a1ef5a5 --- /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 00000000000..68bbd50e4f2 --- /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 00000000000..f86b096c821 --- /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 00000000000..1aef1304579 --- /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 00000000000..8edaa6d3e85 --- /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 00000000000..cf96f574643 --- /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 00000000000..7c503b0ccb6 --- /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 00000000000..b47e39c3898 --- /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 00000000000..4a98b9388ab --- /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 00000000000..319a7c71a65 --- /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 115/219] [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 591ca3eb4dd..566cac066ed 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 05f9a781560..1dc36646026 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 251339fb5ff..b41e850bcd0 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 116/219] [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 48b67e1ef98..56367d0b267 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 00000000000..607ad0c7edc --- /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 07f6bad7716..22351244bd8 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 df8fd0a7560..90c162fedd8 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 2c520d0d2c6..1bc79cc4123 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 00000000000..276dfbbf1c3 --- /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 8202a2bb440..ed7f2c29418 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 2322fdd014f..cc371ee1f9d 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 9f4e4fcca8d..036c7c04541 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 00000000000..a8533608c01 --- /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 00000000000..9652ad08eb9 --- /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 c1d16530cbf..a37b19f5841 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 117/219] [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 566cac066ed..3c1c2be289c 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 +fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da diff --git a/CODEOWNERS b/CODEOWNERS index c5beba8c0b9..c69f8bccd46 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 c22ab7b5521..7094699b0bd 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 00000000000..f9bc1d9df74 --- /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 a3d8f62ce63..00000000000 --- 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 b732e71d24b..bdbf798df52 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 c50e6f6b4da..cdc53d56858 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 293d43a55b6..00000000000 --- 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 1bb960e61e9..00000000000 --- 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 27fd44f0086..ec4639351d9 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 4d2373f4f94..0bfb43a285b 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 4a4bc185799..7cbc2ac4aef 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 b41e850bcd0..d60a4fd68d3 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 edb1c66f0f5..00000000000 --- 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 db064b64f9d..00000000000 --- 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 6aa4e1b0ff9..59d854a3ae5 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 c9910aa600b..bd11a443739 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 1547532f1e0..2d29656c94a 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 119a1978de4..5a05efa259b 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 00000000000..f1df2daf83a --- /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 118/219] [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 7b9c320879e..e42a2c092bb 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 00000000000..a50f1cf2d4a --- /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 41dc2c546d8..d0dccf42b96 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 c8c1ee7df24..5b23f9d685f 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 119/219] 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 ed7f2c29418..62ed506e368 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 120/219] [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 c69f8bccd46..a64d6f3daf8 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 00000000000..943139e19a3 --- /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 00000000000..7545343f027 --- /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 00000000000..3a24d637609 --- /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 00000000000..674d26fe8e2 --- /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 00000000000..feaa46010ef --- /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 00000000000..b47e39c3898 --- /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 00000000000..4a98b9388ab --- /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 121/219] [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 a64d6f3daf8..300ae13cf45 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 00000000000..0e67e41a0e0 --- /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 00000000000..acb93158d4b --- /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 00000000000..7c5a2db015e --- /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 00000000000..4243f4e1579 --- /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 00000000000..c1616665f9c --- /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 00000000000..69235ed8dc2 --- /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 ebb4186a2bc..9951243f0dc 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 00000000000..0ffb1c62813 --- /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 00000000000..b47e39c3898 --- /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 122/219] [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 6fce8a55d9e..0ed357a7598 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 849ef274ed7..1088517ed15 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 123/219] [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 3c1c2be289c..6f6339ff84c 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da +442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 diff --git a/platformio.ini b/platformio.ini index d60a4fd68d3..718dfb672f6 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 124/219] [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 1bc79cc4123..c0e9a0051f9 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 036c7c04541..d89b93f4787 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 125/219] [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 300ae13cf45..10128c64e52 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 00000000000..b1044276a17 --- /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 00000000000..efdd241578f --- /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 00000000000..75462ff1fb2 --- /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 00000000000..8c2c5198eab --- /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 00000000000..980e84a2e9e --- /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 00000000000..832254781f0 --- /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 00000000000..b47e39c3898 --- /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 126/219] [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 e99a630e837..03c005dc276 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 127/219] [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 9cc7a7ff122..9e11d785c06 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 16c1597ccd7..a583279ea7a 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 90c162fedd8..4289cdf3e52 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 151018baa47..582b8fc74da 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 540dd067318..a5c2719f426 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 da0010afa3f..2ae3836a25e 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 128/219] 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 62ed506e368..a825cd9bff8 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 129/219] [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 8a13110631f..ea080adc6bb 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 ecbd445a806..f1ebd180cc0 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 4a091e298a6..f482ce4b883 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 782dc831c4b..65dd5590ee0 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 130/219] [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 183820256f4..e6fcc018d91 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 206df2c8443..4ee703f363c 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 c58c42f0d83..70f9214e2d8 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 e0b0c1e8d3a4763e255a45a7fa9eb0ebe1392110 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 131/219] Bump version to 2026.7.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996b..9f4e20b977f 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.7.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 22351244bd8..3ca7b2e6188 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.7.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( 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 132/219] 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 3537516996b..647d25559a4 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 22351244bd8..9a951c15271 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 6a527c7efc24a3a14b5d29db862810ee830bc7c5 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 133/219] [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 acc268fa686..a9defcacac7 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: 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 134/219] [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 acc268fa686..a9defcacac7 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: From 750cf1995b894a80fcae6c875a0a60d3c56beee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 135/219] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6d..db94f0ec6d2 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d62..f231ac5fb74 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 28dd935359ea59270c18b463f858041eed35ef25 Mon Sep 17 00:00:00 2001 From: Dan Drown Date: Thu, 11 Jun 2026 11:35:44 -0500 Subject: [PATCH 136/219] [xpt2046] touchscreen driver enhancement (#16414) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../xpt2046/touchscreen/xpt2046.cpp | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index d08a54529d8..83a73320054 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -6,6 +6,13 @@ namespace esphome::xpt2046 { +static constexpr uint8_t XPT_READ_Z1 = 0xB0; +static constexpr uint8_t XPT_READ_Z2 = 0xC0; +static constexpr uint8_t XPT_READ_X = 0xD0; +static constexpr uint8_t XPT_READ_Y = 0x90; +static constexpr uint8_t XPT_ADC_ON = 0x01; +static constexpr uint8_t XPT_VREF_ON = 0x02; + static const char *const TAG = "xpt2046"; void XPT2046Component::setup() { @@ -20,7 +27,7 @@ void XPT2046Component::setup() { this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE); } this->spi_setup(); - this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin + this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin } void XPT2046Component::update_touches() { @@ -29,21 +36,22 @@ void XPT2046Component::update_touches() { enable(); - int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */); - int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */); + int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON); + int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON); z_raw = touch_pressure_1 + 0xfff - touch_pressure_2; ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw); touch = (z_raw >= this->threshold_); if (touch) { - read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy - data[0] = this->read_adc_(0x91 /* Y */); - data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements - data[2] = this->read_adc_(0x91 /* Y */); - data[3] = this->read_adc_(0xD1 /* X */); - data[4] = this->read_adc_(0x91 /* Y */); + read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy + // make 3 x-y measurements + data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); } - data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down + data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down disable(); @@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v return reta; } -int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT - uint8_t data[2]; +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[3]; - this->write_byte(ctrl); - delay(1); - data[0] = this->read_byte(); - data[1] = this->read_byte(); + data[0] = ctrl; + data[1] = 0; + data[2] = 0; - return ((data[0] << 8) | data[1]) >> 3; + this->transfer_array(data, sizeof(data)); + + return ((data[1] << 8) | data[2]) >> 3; } } // namespace esphome::xpt2046 From 6ef35b6d3d163d0d027866b10805c617a841bfdc Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 137/219] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1ae..0731078eeca 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 88084f2ec712ef015c51feb57f1d0bbaf7955737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:32:51 -0400 Subject: [PATCH 138/219] Bump ruff from 0.15.16 to 0.15.17 (#16918) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9da27acc19a..5ba806a2f57 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.16 # also change in .pre-commit-config.yaml when updating +ruff==0.15.17 # 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 bf6c8568d364b8c2d76c29aba756c9ebd4651ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:33:28 -0400 Subject: [PATCH 139/219] Bump CodSpeedHQ/action from 4.17.0 to 4.17.5 (#16919) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 a57be34e9b4..deeec720955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 with: run: | . venv/bin/activate From 10ce6024bf2339b888a5182aea1230634d789d69 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 140/219] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960b..9137412abe5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c4..d7df6289071 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( 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) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548c..61845c4b25d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fefe..badd4686f68 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 35e5c7c7c353ab8182d3c74a65b434e1738ec2e3 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sat, 13 Jun 2026 23:40:49 +0200 Subject: [PATCH 141/219] [runtime_image] Improve error logging (#16943) --- esphome/components/online_image/online_image.cpp | 3 ++- esphome/components/runtime_image/image_decoder.h | 16 ++++++++++++++++ .../components/runtime_image/jpeg_decoder.cpp | 16 ++++++++++++++-- esphome/components/runtime_image/png_decoder.cpp | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a5a3ea51041..22bce4cc418 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,4 +1,5 @@ #include "online_image.h" +#include "esphome/components/runtime_image/image_decoder.h" #include "esphome/core/log.h" #include @@ -181,7 +182,7 @@ void OnlineImage::loop() { auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed < 0) { - ESP_LOGE(TAG, "Error decoding image: %d", consumed); + ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed)); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 926108a8a0e..c68ea5720b6 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -7,8 +7,24 @@ enum DecodeError : int { DECODE_ERROR_INVALID_TYPE = -1, DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, + DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, }; +constexpr const char *decode_error_to_string(int error) { + switch (error) { + case DECODE_ERROR_INVALID_TYPE: + return "Invalid type"; + case DECODE_ERROR_UNSUPPORTED_FORMAT: + return "Unsupported format"; + case DECODE_ERROR_OUT_OF_MEMORY: + return "Out of memory"; + case DECODE_ERROR_INTERNAL_DECODER_ERROR: + return "Internal decoder error"; + default: + return "Unknown error"; + } +} + class RuntimeImage; /** diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index dcaa07cd58c..c46e86fd0d9 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return DECODE_ERROR_OUT_OF_MEMORY; } if (!this->jpeg_.decode(0, 0, 0)) { - ESP_LOGE(TAG, "Error while decoding."); + auto error = this->jpeg_.getLastError(); + ESP_LOGE(TAG, "Error while decoding: %d", error); this->jpeg_.close(); - return DECODE_ERROR_UNSUPPORTED_FORMAT; + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } } this->decoded_bytes_ = size; this->jpeg_.close(); diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 591504328d8..12bce0d284f 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { auto fed = pngle_feed(this->pngle_, buffer, size); if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); + return DECODE_ERROR_INTERNAL_DECODER_ERROR; } else { this->decoded_bytes_ += fed; } From 5b7f8cf90d0d78a0563cd342b718fa6fd75992e5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 142/219] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919a..129befe600d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a79..896140b4b19 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726a..1eacc31fc58 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): 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) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff5005..abb7eaa4585 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf80891..a594e482098 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb9073..5df7a275dff 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de1..3c719b0f5e2 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d8..d681908027d 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 00000000000..82adf88b7e0 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +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, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + 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": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + 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": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + 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": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From 1e5771a3fa446c0de961a9a667efc19c8002ec5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 143/219] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84c..7497cc3679f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b1..8728e02a346 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d3..d3f4d151c21 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # 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"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4aef..c97e8906a8c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f6..862b7a7dbe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ 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.git +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 framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + 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.git +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 framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543c..cb25535d8d2 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, 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) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From e191fc5d47284c2e0609c4fe368847d1fb33e79f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 144/219] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e52..21ff7ef07c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80cb..b925f0b7d96 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36a..cfd42916b2b 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + 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) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: 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, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f25408..e2b34d92d82 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 00000000000..c93e828f317 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9d..a61b6ae7aec 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff039422..87e168dc94b 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + 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) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> 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): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> 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): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From efebea32969ba72ce34524798f057064ffb7f766 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 145/219] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b1278147..d703e22e462 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 00000000000..0d051420994 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 00000000000..7c7f50a4399 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d42..a8b5720a80b 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From 83504d2de2567619cdee1770a9bfbce36ff8da11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 146/219] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6d..db94f0ec6d2 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d62..f231ac5fb74 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 20925b32207ebd70060bc0c21799de862a447fd4 Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 147/219] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1ae..0731078eeca 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 26ccaf70dbb3e4e6d422c1cd2584973edbc06647 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 148/219] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960b..9137412abe5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c4..d7df6289071 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( 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) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548c..61845c4b25d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fefe..badd4686f68 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 9ffd350095e5836de12a3110fef73a95e77bdb53 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 149/219] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919a..129befe600d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a79..896140b4b19 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726a..1eacc31fc58 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): 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) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff5005..abb7eaa4585 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf80891..a594e482098 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb9073..5df7a275dff 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de1..3c719b0f5e2 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d8..d681908027d 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 00000000000..82adf88b7e0 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +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, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + 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": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + 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": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + 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": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From c768e2eabc1cc88a6b78437c42d23ed04b171199 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 150/219] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84c..7497cc3679f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b1..8728e02a346 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d3..d3f4d151c21 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # 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"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4aef..c97e8906a8c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f6..862b7a7dbe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ 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.git +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 framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + 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.git +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 framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543c..cb25535d8d2 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, 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) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From f83e3ad6a6c4d7fdb2dfd20be815313f2afe6e81 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 151/219] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e52..21ff7ef07c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80cb..b925f0b7d96 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36a..cfd42916b2b 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + 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) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: 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, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f25408..e2b34d92d82 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 00000000000..c93e828f317 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9d..a61b6ae7aec 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff039422..87e168dc94b 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + 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) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> 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): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> 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): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): 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])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From 99425e3a976ac8c5c9602ffffa35a3b987f9d779 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 152/219] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b1278147..d703e22e462 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 00000000000..0d051420994 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 00000000000..7c7f50a4399 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d42..a8b5720a80b 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From a46aa594b33b11c252b7eb38002e92568e1f3aa4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:04:46 +1200 Subject: [PATCH 153/219] Bump version to 2026.6.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 647d25559a4..809f934797f 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.0b1 +PROJECT_NUMBER = 2026.6.0b2 # 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 9a951c15271..27abfa2dd22 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.0b1" +__version__ = "2026.6.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f1fd5f2f4957849602f7903c7d70dc57e119671e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:10:58 +1000 Subject: [PATCH 154/219] [epaper_spi] Metadata, bug fixes, new model (#16950) --- esphome/components/epaper_spi/display.py | 22 +- esphome/components/epaper_spi/epaper_spi.cpp | 4 + esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/models/ssd1677.py | 17 +- tests/component_tests/conftest.py | 38 ++++ .../epaper_spi/config/enable_pin_test.yaml | 24 +++ .../epaper_spi/test_display_metadata.py | 156 ++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 190 ++++++++++++++---- tests/component_tests/mipi_spi/conftest.py | 39 +--- 9 files changed, 412 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/epaper_spi/config/enable_pin_test.yaml create mode 100644 tests/component_tests/epaper_spi/test_display_metadata.py diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a7..ce28fb0d67e 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -13,6 +13,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BUSY_PIN, CONF_CS_PIN, CONF_DATA_RATE, @@ -129,7 +130,23 @@ def customise_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + model = MODELS[config[CONF_MODEL]] + config = model_schema(config)(config) + width, height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=True, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=0, + ) + return config CONFIG_SCHEMA = customise_schema @@ -197,6 +214,9 @@ async def to_code(config): if busy_pin := config.get(CONF_BUSY_PIN): busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a2ca311b305..3214f932bfb 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } void EPaperBase::setup_pins_() const { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } this->dc_pin_->setup(); // OUTPUT this->dc_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 2992ca5afda..8e2fd78e621 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -50,6 +50,7 @@ class EPaperBase : public Display, float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } void set_transform(uint8_t transform) { this->transform_ = transform; @@ -177,6 +178,7 @@ class EPaperBase : public Display, GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + std::vector enable_pins_{}; bool waiting_for_idle_{}; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index bad33a6a023..13f10350457 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -10,11 +10,11 @@ class SSD1677(EpaperModel): # fmt: off def get_init_sequence(self, config: dict): - width, _height = self.get_dimensions(config) + _width, height = self.get_dimensions(config) return ( (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 - (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) (0x3C, 0x01), # Set border waveform (0x11, 3), # Set transform ) @@ -51,3 +51,16 @@ ssd1677.extend( height=480, mirror_x=True, ) + +ssd1677.extend( + "seeed-reterminal-sticky", + width=800, + height=480, + mirror_x=True, + enable_pin=47, + cs_pin=15, + dc_pin=16, + reset_pin=17, + busy_pin=18, + data_rate="10MHz", +) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 763628f57c9..3730978ec3c 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]: return setter +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """Set the ESP32 variant to the first one on which all the given pins are valid. + + For ESP32 only, since the other platforms do not have variants. The core + configuration must already have been set up for an ESP32 target. + Using local imports to avoid importing when ESP32 is not the target. + """ + from esphome import config_validation as cv + from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS + from esphome.components.esp32.gpio import validate_gpio_pin + from esphome.const import CONF_INPUT, CONF_OUTPUT + from esphome.pins import gpio_pin_schema + + def chooser(pins: list) -> None: + for variant in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser + + @pytest.fixture def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: """Return a function to get absolute paths relative to the component's fixtures directory.""" diff --git a/tests/component_tests/epaper_spi/config/enable_pin_test.yaml b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml new file mode 100644 index 00000000000..d238cd1d9e3 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + enable_pin: + - GPIO25 + - GPIO26 + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_display_metadata.py b/tests/component_tests/epaper_spi/test_display_metadata.py new file mode 100644 index 00000000000..95afefcf354 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_display_metadata.py @@ -0,0 +1,156 @@ +"""Tests for display metadata created by the epaper_spi component.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from esphome import config_validation as cv +from esphome.components.display import get_all_display_metadata, get_display_metadata +from esphome.components.epaper_spi.display import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _base_config(**overrides: Any) -> ConfigType: + """Build a minimal valid ssd1677 config, allowing field overrides.""" + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 300}, + } + config.update(overrides) + return config + + +def test_metadata_dimensions_and_defaults( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Metadata picks up explicit dimensions and epaper_spi defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config()) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 200 + assert meta.height == 300 + # epaper_spi always reports full hardware rotation + assert meta.has_hardware_rotation is True + # epaper_spi does not declare a byte order + assert meta.byte_order is cv.UNDEFINED + assert meta.draw_rounding == 0 + # no drawing methods configured -> no writer + assert meta.has_writer is False + + +def test_metadata_default_dimensions_from_model( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model with built-in dimensions reports those without explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + # waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions + config = CONFIG_SCHEMA( + { + "id": "wave_display", + "model": "waveshare-4.26in", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + } + ) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 800 + assert meta.height == 480 + + +def test_metadata_has_writer_with_auto_clear( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A display with auto_clear_enabled reports has_writer=True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_rotation_propagated( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The configured rotation is stored in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(rotation=90)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Each display gets its own independent metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300})) + CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480})) + + all_meta = get_all_display_metadata() + assert all_meta["disp_a"].width == 200 + assert all_meta["disp_a"].height == 300 + assert all_meta["disp_b"].width == 400 + assert all_meta["disp_b"].height == 480 + + +def test_metadata_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Full code generation registers metadata for the configured display.""" + generate_main(component_config_path("enable_pin_test.yaml")) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + # enable_pin_test.yaml: ssd1677 at 200x200 + assert meta.width == 200 + assert meta.height == 200 + assert meta.has_hardware_rotation is True diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a9f5735fcab..c7f34d7dd26 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -1,6 +1,8 @@ """Tests for epaper_spi configuration validation.""" from collections.abc import Callable +from pathlib import Path +import re from typing import Any import pytest @@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import ( - KEY_BOARD, - KEY_VARIANT, - VARIANT_ESP32, - VARIANT_ESP32S3, -) +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_ENABLE_PIN, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_RESET_PIN, @@ -31,6 +29,30 @@ from esphome.const import ( from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +# Pin options whose values must be valid on the chosen ESP32 variant. +_PIN_CONF_KEYS = ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_RESET_PIN, + CONF_BUSY_PIN, + CONF_ENABLE_PIN, +) + + +def _pins_for(model: Any, config: ConfigType) -> list: + """Collect every GPIO the config will actually use (model defaults or injected).""" + pins: list = [] + for key in _PIN_CONF_KEYS: + # An injected value in the config takes precedence over the model default. + value = config[key] if key in config else model.get_default(key) + if not value: # get_default returns False for pins the model omits + continue + if isinstance(value, list): + pins.extend(value) + else: + pins.append(value) + return pins + def run_schema_validation( config: ConfigType, with_final_validate: bool = False @@ -90,29 +112,20 @@ def test_basic_configuration_errors( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined epaper models validate successfully with appropriate defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + # Test all models, providing default values where necessary for name, model in MODELS.items(): - # SEEED models are designed for ESP32-S3 hardware - if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - - # Configure SPI component which is required by epaper_spi - set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - config = {"model": name} # Add ID field @@ -141,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + run_schema_validation(config) @@ -152,27 +169,19 @@ def test_individual_models( model_name: str, set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test each epaper model individually to ensure it validates correctly.""" - # SEEED models are designed for ESP32-S3 hardware - if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) + model = MODELS[model_name] + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) # Configure SPI component which is required by epaper_spi set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - model = MODELS[model_name] config: dict[str, Any] = {"model": model_name, "id": "test_display"} # Add required fields based on model defaults @@ -195,6 +204,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + # This should not raise any exceptions run_schema_validation(config) @@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677( reset_pin_config = result[CONF_RESET_PIN] assert "mode" in reset_pin_config assert reset_pin_config["mode"]["output"] is True + + +def test_enable_pin_single( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a single enable_pin is accepted and normalised to a list of output pins.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": 25, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + # A single pin is normalised to a list by cv.ensure_list + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 1 + # enable pins are configured as outputs + assert enable_pins[0]["mode"]["output"] is True + + +def test_enable_pin_multiple( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a list of enable_pins is accepted.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": [25, 26], + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 2 + assert all(pin["mode"]["output"] is True for pin in enable_pins) + + +def test_enable_pin_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that enable_pins are wired up in the generated C++ code.""" + main_cpp = generate_main(component_config_path("enable_pin_test.yaml")) + + # Derive the auto-generated pin variable names from the set_pin() lines + # rather than hard-coding them, so the test does not break when unrelated + # codegen details shift the generated IDs. + def pin_var_for(gpio_num: int) -> str: + match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp) + assert match is not None, ( + f"GPIO_NUM_{gpio_num} pin not set up in generated code" + ) + return match.group(1) + + pin_25 = pin_var_for(25) + pin_26 = pin_var_for(26) + + # Both pin objects must be passed to the display via set_enable_pins() as a + # std::vector initializer list, in the configured order. + assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index 082a9e55f2a..ed48056f63d 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,16 +1,10 @@ """Tests for mpip_spi configuration validation.""" -from collections.abc import Callable, Generator from unittest import mock import pytest -from esphome import config_validation as cv -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS -from esphome.components.esp32.gpio import validate_gpio_pin -from esphome.const import CONF_INPUT, CONF_OUTPUT -from esphome.core import CORE -from esphome.pins import gpio_pin_schema +# choose_variant_with_pins is provided by the shared parent conftest. @pytest.fixture(autouse=True) @@ -21,34 +15,3 @@ def mock_spi_final_validate(): return_value=lambda config: None, ): yield - - -@pytest.fixture -def choose_variant_with_pins() -> Generator[Callable[[list], None]]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(pins: list) -> None: - for variant in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - for pin in pins: - if pin is not None: - pin = gpio_pin_schema( - { - CONF_INPUT: True, - CONF_OUTPUT: True, - }, - internal=True, - )(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - raise cv.Invalid( - f"No compatible variant found for pins: {', '.join(map(str, pins))}" - ) - - yield chooser From c1a7a8ff55e2384e89a9958c0bec3e6b69ba31c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:01:44 +1200 Subject: [PATCH 155/219] Add PEP 572 walrus operator preference to coding conventions (#16951) --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4adc53cae97..4346ffbdae0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations +* **Python Idioms:** + * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: + ```python + # Bad - looks up CONF_BLAH twice + if CONF_BLAH in config: + cg.add(var.set_blah(config[CONF_BLAH])) + + # Good - single lookup, value bound inline + if (blah := config.get(CONF_BLAH)) is not None: + cg.add(var.set_blah(blah)) + ``` + The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: From 1ee49720c7fe4a112b8b7604a13cdf2044fae965 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 156/219] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e462..5d4b3b8b476 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority 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.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997d..296ea6c08c7 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25d..974957245a7 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # 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 diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc9..ea4adc69a99 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 00000000000..3fa6360d144 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 00000000000..9c04284163a --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 00000000000..3e5899061f7 --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f68..8bbaa2773aa 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 963465a0a6977db8693057b7609de64ffde613a6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 157/219] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd41..53fac9b5349 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 3420cff31647983904e4bd6289aed972fcd8142f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 158/219] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e6728..1e4910453a9 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd2..6cd33e566cc 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46d..7c12a66b2cf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e7..cf8b05a3009 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897b..7ef5ff50a53 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f16..299a5f72eaa 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4b..7297d223572 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486db..15bb9ea2398 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fea..378c0fb94b7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 00000000000..594ec46afb5 --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 00000000000..2d8a62f25b6 --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 00000000000..860fa00c374 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 00000000000..699a5bc746c --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From 7a2657cea19b5ce831b52a2682f6bae5fb62bfd7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 159/219] [audio] Bump microMP3 to v0.2.3 (#16977) --- .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 7497cc3679f..7a3cfc7a03b 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef4..2aceff0c97e 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.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _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 c97e8906a8c..04220488cc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From a7a407c22c255f0cb4e3bb5014415e4268a60327 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 160/219] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636c..c8ffc02131a 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50e..96f1abdb924 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c7..4d88cbd2264 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f04..ef7c5cc8e9f 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 73f839437ea4450b786ca6d767def371f7bdc015 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 161/219] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b911..494c0cebe80 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b63..c360ae1a4a2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # 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 libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From bb6cd97948206d38469eba878e53a806292afaa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:15:12 -0400 Subject: [PATCH 162/219] Bump clang-tidy from 22.1.0.1 to 22.1.7 (#16984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- requirements_dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03b..1f709bb90d7 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 diff --git a/requirements_dev.txt b/requirements_dev.txt index 31463e07c37..7e66c7244d6 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.0.1 +clang-tidy==22.1.7 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From b09a5f9e43efd49abed4d7a2845758d2f37fd257 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 163/219] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b1..7d4b8503567 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88df..475986e905a 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 00000000000..34bcc4e714e --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From d8fa0e414093cc8625ec6f4ce538bd4352b8d56f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 164/219] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8de..c622a2dd365 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ 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 +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9d..ef7e2d0b93f 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6cab..c97df812e34 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc89..b2309439f98 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + 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 diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a3..70c4b900823 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f5841..568b43a2595 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 930cf2b5b94dcf8143aa4a5afd236b72ee1cc668 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 165/219] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a2..c7634cf1c8f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244ca..18baf40c29b 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b9904697626..00000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1a..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079ad..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c0..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9bf..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc885..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596de..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f2..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90f..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde7105..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d3015..bb36cfcdb4f 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a4..da450c25f99 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi 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 deleted file mode 100755 index b8251e8e018..00000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# 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." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd1..00000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2d..00000000000 From 32ab3abd7c0cb4cbca7bb43cb8e20cf637395f02 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 166/219] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e462..5d4b3b8b476 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority 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.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997d..296ea6c08c7 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25d..974957245a7 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # 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 diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc9..ea4adc69a99 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 00000000000..3fa6360d144 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 00000000000..9c04284163a --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 00000000000..3e5899061f7 --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f68..8bbaa2773aa 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 33ace9d698a8ff8e6bed06a71b741b38bed2ecc7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 167/219] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd41..53fac9b5349 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 9bf35ab8fbc69847a4f7b292784946cbc8e2e37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 168/219] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e6728..1e4910453a9 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd2..6cd33e566cc 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46d..7c12a66b2cf 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e7..cf8b05a3009 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897b..7ef5ff50a53 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f16..299a5f72eaa 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4b..7297d223572 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486db..15bb9ea2398 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fea..378c0fb94b7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 00000000000..594ec46afb5 --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 00000000000..2d8a62f25b6 --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 00000000000..860fa00c374 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 00000000000..699a5bc746c --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From aef9b5b72f731ff6d8d307e71cfbdcf37dcb52c5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 169/219] [audio] Bump microMP3 to v0.2.3 (#16977) --- .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 7497cc3679f..7a3cfc7a03b 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef4..2aceff0c97e 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.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _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 c97e8906a8c..04220488cc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 1d38498ca7c27609dd166610397909cdcad8d174 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 170/219] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636c..c8ffc02131a 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50e..96f1abdb924 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c7..4d88cbd2264 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f04..ef7c5cc8e9f 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 66be793cd8a57af57c032e5ab207a34b5f83caa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 171/219] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b911..494c0cebe80 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b63..c360ae1a4a2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # 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 libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From 0ce89c17ab7233216c78a7e66875477f08d0acf3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 172/219] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b1..7d4b8503567 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88df..475986e905a 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 00000000000..34bcc4e714e --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From 0422b581cb1537b6ceebb1b12546911a51efc43b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 173/219] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8de..c622a2dd365 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ 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 +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9d..ef7e2d0b93f 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6cab..c97df812e34 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc89..b2309439f98 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + 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 diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a3..70c4b900823 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f5841..568b43a2595 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 310baab5248a4fd28cc4c7941f2b716be63e3482 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 174/219] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a2..c7634cf1c8f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244ca..18baf40c29b 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b9904697626..00000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1a..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079ad..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c0..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9bf..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc885..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596de..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f2..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90f..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde7105..00000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d3015..bb36cfcdb4f 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a4..da450c25f99 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi 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 deleted file mode 100755 index b8251e8e018..00000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# 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." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd1..00000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2d..00000000000 From 53fd99578ae69088a4a93bc7fc8ad9b3f4932969 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:02:55 +1200 Subject: [PATCH 175/219] Bump version to 2026.6.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 809f934797f..c94ea34387d 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.0b2 +PROJECT_NUMBER = 2026.6.0b3 # 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 27abfa2dd22..bf770ae5b5c 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.0b2" +__version__ = "2026.6.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ce11d38c9bac04b1dfe5570cb289399baff6e6f0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 176/219] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__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 1f709bb90d7..591ce70a628 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 +6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9f..7f420f27d8c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,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.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") 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 04220488cc3..5f3000e52d0 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 29e8949e3e66c1cea04b5a16ab32b87eb539bd6f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 177/219] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1fb..ac765d8018f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From e80461eba972acaad9e0592a912948c9855e7f83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 178/219] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8f..8e7580490f8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From 009c6dd9957df088017cae2e34617728f794d1d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 179/219] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f8..185a0740ed9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 40d0cbee3fea57b43eb3dfd7d8181588b1efef56 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:07:36 -0500 Subject: [PATCH 180/219] Bump bundled esphome-device-builder to 1.0.5 (#17014) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed9..980791013f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 RUN \ platformio settings set enable_telemetry No \ From 900e0b8566a535b58a4ce14a9b07c720bcedf71f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 181/219] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 980791013f7..706dd93e671 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From 0f5defa67eebccbbca0b997d8e4fd3ec0192b8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:51:08 -0500 Subject: [PATCH 182/219] Bump tzlocal from 5.3.1 to 5.4.3 (#17015) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff8..4ef3df60ffc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 tornado==6.5.7 -tzlocal==5.3.1 # from time +tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 24e276c3f96a8a75eb1ca795e56eca8d90332625 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 183/219] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__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 7a3cfc7a03b..84daffc69f6 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6 diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9f..7f420f27d8c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,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.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") 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 04220488cc3..5f3000e52d0 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 045de436ba8e5c002babc54fc8f42a2ee26cd0aa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 184/219] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1fb..ac765d8018f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From 41f7f8cccb143b8c46ef1cd8d90c60184150f910 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 185/219] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8f..8e7580490f8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From cdd2bfbc609ec3d1cafb2a87a6340458f3a06b6b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 186/219] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f8..185a0740ed9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 7ab95ddcb1668db200ab76d5cfff39271330a0ad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 187/219] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed9..706dd93e671 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From db6b9166f457ecb9041a85212cfbe425ec423272 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:20:15 +1200 Subject: [PATCH 188/219] Bump version to 2026.6.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c94ea34387d..aab6c1000de 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.0b3 +PROJECT_NUMBER = 2026.6.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index bf770ae5b5c..1386565d78a 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.0b3" +__version__ = "2026.6.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6d9490b5a361459c1f5b1009e372a46a310fed9b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 189/219] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e671..b48ba64aa8e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From 9e7b3e033084fe5cdd29d42c7439347394053678 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:18:37 +1200 Subject: [PATCH 190/219] Bump version to 2026.6.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index aab6c1000de..56879237d49 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.0b4 +PROJECT_NUMBER = 2026.6.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 1386565d78a..c045e452f7e 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.0b4" +__version__ = "2026.6.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ae7c800de826aee6a0a8aa548c7c93c96dd484a8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 191/219] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8e..c199f2edbd5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From 77a99bceb2739a3cd8e857e705fc9d22299c8ed9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 192/219] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd5..18a99037351 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 9ac22f924405223df483927c5d9ab4b021e99db0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 193/219] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e671..b48ba64aa8e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From c4076ec8a99c5781065477be90e7153fbd74f3dc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 194/219] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8e..c199f2edbd5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From d934fb3910fc4d96806f4f29b3aff533b498a472 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 195/219] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd5..18a99037351 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 7cb6cf2f2a46436117c370ce303dda2055fc6e38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:12:39 -0400 Subject: [PATCH 196/219] [ci] Replace clang-tidy hash with direct config-file diff check (#17019) --- .clang-tidy.hash | 1 - .github/workflows/ci-clang-tidy-hash.yml | 76 ----- .github/workflows/ci.yml | 38 +-- .pre-commit-config.yaml | 9 +- script/ci-custom.py | 2 +- script/clang_tidy_hash.py | 208 +++----------- script/determine-jobs.py | 65 ++--- tests/script/test_clang_tidy_hash.py | 351 +++-------------------- tests/script/test_determine_jobs.py | 48 ++-- 9 files changed, 124 insertions(+), 674 deletions(-) delete mode 100644 .clang-tidy.hash delete mode 100644 .github/workflows/ci-clang-tidy-hash.yml mode change 100755 => 100644 script/clang_tidy_hash.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 591ce70a628..00000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index 73c437467b5..00000000000 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Clang-tidy Hash CI - -on: - pull_request: - paths: - - ".clang-tidy" - - "platformio.ini" - - "requirements_dev.txt" - - "sdkconfig.defaults" - - ".clang-tidy.hash" - - "script/clang_tidy_hash.py" - - ".github/workflows/ci-clang-tidy-hash.yml" - -permissions: - contents: read # actions/checkout for the PR head - pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date - -jobs: - verify-hash: - name: Verify clang-tidy hash - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - - name: Verify hash - run: | - python script/clang_tidy_hash.py --verify - - - if: failure() - name: Show hash details - run: | - python script/clang_tidy_hash.py - echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY - echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY - echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - - if: failure() && github.event.pull_request.head.repo.full_name == github.repository - name: Request changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - await github.rest.pulls.createReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - event: 'REQUEST_CHANGES', - body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' - }) - - - if: success() && github.event.pull_request.head.repo.full_name == github.repository - name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let reviews = await github.rest.pulls.listReviews({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo - }); - for (let review of reviews.data) { - if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { - await github.rest.pulls.dismissReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - review_id: review.id, - message: 'Clang-tidy hash now matches configuration.' - }); - } - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deeec720955..1b1032bcde7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,15 +537,12 @@ jobs: 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. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. 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 @@ -607,15 +604,12 @@ jobs: 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. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. 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 @@ -691,15 +685,12 @@ jobs: 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. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. 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 @@ -779,15 +770,12 @@ jobs: 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. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. 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 @@ -1049,7 +1037,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5e..ba74aff07cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit # Skip hooks that have issues in pre-commit CI environment - skip: [pylint, clang-tidy-hash] + skip: [pylint] repos: - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,13 +59,6 @@ repos: language: system types: [python] files: ^esphome/.+\.py$ - - id: clang-tidy-hash - 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|sdkconfig\.defaults|esphome/idf_component\.yml)$ - pass_filenames: false - additional_dependencies: [] - id: ci-custom name: ci-custom entry: python script/run-in-env.py script/ci-custom.py diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781c..cbc54ce55d3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -276,7 +276,7 @@ def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) +@lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4c..00bcaf45b01 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,32 @@ -#!/usr/bin/env python3 -"""Calculate and manage hash for clang-tidy configuration.""" +"""Files that affect clang-tidy results, and a content hash over them. + +``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single +source of truth for which files influence clang-tidy output. A change to any of +them can surface warnings in source files a PR didn't touch, so: + +* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and +* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by + ``script/helpers.py`` (a content hash, unlike an mtime check, stays correct + across git checkouts). +""" from __future__ import annotations -import argparse import hashlib from pathlib import Path -import re -import sys -# Add the script directory to path to import helpers -script_dir = Path(__file__).parent -sys.path.insert(0, str(script_dir)) +# Root-relative paths whose contents affect clang-tidy results. +CLANG_TIDY_GLOBAL_FILES = ( + ".clang-tidy", + "platformio.ini", + "requirements_dev.txt", + "esphome/idf_component.yml", +) - -def read_file_lines(path: Path) -> list[str]: - """Read lines from a file.""" - with path.open() as f: - return f.readlines() - - -def parse_requirement_line(line: str) -> tuple[str, str] | None: - """Parse a requirement line and return (package, original_line) or None. - - Handles formats like: - - package==1.2.3 - - package==1.2.3 # comment - - package>=1.2.3,<2.0.0 - """ - original_line = line.strip() - - # Extract the part before any comment for parsing - parse_line = line - if "#" in parse_line: - parse_line = parse_line[: parse_line.index("#")] - - parse_line = parse_line.strip() - if not parse_line: - return None - - # Use regex to extract package name - # This matches package names followed by version operators - match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) - if match: - return (match.group(1), original_line) # Return package name and original line - - return None - - -def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> str: - """Get clang-tidy version from requirements_dev.txt""" - repo_root = _ensure_repo_root(repo_root) - requirements_path = repo_root / "requirements_dev.txt" - lines = read_file_lines(requirements_path) - - for line in lines: - parsed = parse_requirement_line(line) - if parsed and parsed[0] == "clang-tidy": - # Return the original line (preserves comments) - return parsed[1] - - return "clang-tidy version not found" +# sdkconfig.defaults and per-target sdkconfig.defaults. files flip the +# CONFIG flags that decide which variant code paths clang-tidy sees. Matched by +# this prefix at the repo root. +SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults" def read_file_bytes(path: Path) -> bytes: @@ -80,130 +46,20 @@ def _ensure_repo_root(repo_root: Path | None) -> Path: def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: - """Calculate hash of clang-tidy configuration and version""" + """Calculate a hash of the files that affect clang-tidy results.""" repo_root = _ensure_repo_root(repo_root) hasher = hashlib.sha256() - # Hash .clang-tidy file - clang_tidy_path = repo_root / ".clang-tidy" - content = read_file_bytes(clang_tidy_path) - hasher.update(content) + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + if path.exists(): + hasher.update(read_file_bytes(path)) - # Hash clang-tidy version from requirements_dev.txt - version = get_clang_tidy_version_from_requirements(repo_root) - hasher.update(version.encode()) - - # Hash the entire platformio.ini file - platformio_path = repo_root / "platformio.ini" - platformio_content = read_file_bytes(platformio_path) - hasher.update(platformio_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. - idf_component_path = repo_root / "esphome" / "idf_component.yml" - if idf_component_path.exists(): - hasher.update(read_file_bytes(idf_component_path)) + # Hash each sdkconfig.defaults* file. Include the filename so adding or + # renaming a per-target variant is detected, not just content edits. + for path in sorted(repo_root.glob(f"{SDKCONFIG_DEFAULTS_PREFIX}*")): + hasher.update(path.name.encode()) + hasher.update(read_file_bytes(path)) return hasher.hexdigest() - - -def read_stored_hash(repo_root: Path | None = None) -> str | None: - """Read the stored hash from file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - if hash_file.exists(): - lines = read_file_lines(hash_file) - return lines[0].strip() if lines else None - return None - - -def write_file_content(path: Path, content: str) -> None: - """Write content to a file.""" - with path.open("w") as f: - f.write(content) - - -def write_hash(hash_value: str, repo_root: Path | None = None) -> None: - """Write hash to file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - # Strip any trailing newlines to ensure consistent formatting - write_file_content(hash_file, hash_value.strip() + "\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") - parser.add_argument( - "--check", - action="store_true", - help="Check if full scan needed (exit 0 if needed)", - ) - parser.add_argument("--update", action="store_true", help="Update the hash file") - parser.add_argument( - "--update-if-changed", - action="store_true", - help="Update hash only if configuration changed (for pre-commit)", - ) - parser.add_argument( - "--verify", action="store_true", help="Verify hash matches (for CI)" - ) - - args = parser.parse_args() - - current_hash = calculate_clang_tidy_hash() - stored_hash = read_stored_hash() - - if args.check: - # Check if hash changed OR if .clang-tidy.hash was updated in this PR - # This is used in CI to determine if a full clang-tidy scan is needed - hash_changed = current_hash != stored_hash - - # Lazy import to avoid requiring dependencies that aren't needed for other modes - from helpers import changed_files # noqa: E402 - - hash_file_updated = ".clang-tidy.hash" in changed_files() - - # Exit 0 if full scan needed - sys.exit(0 if (hash_changed or hash_file_updated) else 1) - - elif args.verify: - # Verify that hash file is up to date with current configuration - # This is used in pre-commit and CI checks to ensure hash was updated - if current_hash != stored_hash: - print("ERROR: Clang-tidy configuration has changed but hash not updated!") - print(f"Expected: {current_hash}") - print(f"Found: {stored_hash}") - print("\nPlease run: script/clang_tidy_hash.py --update") - sys.exit(1) - print("Hash verification passed") - - elif args.update: - write_hash(current_hash) - print(f"Hash updated: {current_hash}") - - elif args.update_if_changed: - if current_hash != stored_hash: - write_hash(current_hash) - print(f"Clang-tidy hash updated: {current_hash}") - # Exit 0 so pre-commit can stage the file - sys.exit(0) - else: - print("Clang-tidy hash unchanged") - sys.exit(0) - - else: - print(f"Current hash: {current_hash}") - print(f"Stored hash: {stored_hash}") - print(f"Match: {current_hash == stored_hash}") - - -if __name__ == "__main__": - main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 94a78e8423f..4904883ca94 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -55,10 +55,10 @@ from functools import cache import json import os from pathlib import Path -import subprocess import sys from typing import Any +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, @@ -280,23 +280,22 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s @cache -def _is_clang_tidy_full_scan() -> bool: - """Check if clang-tidy configuration changed (requires full scan). +def _is_clang_tidy_full_scan(branch: str | None = None) -> bool: + """Check if a clang-tidy-relevant config file changed (requires full scan). + + A change to a file that affects clang-tidy globally can surface warnings in + source files the PR didn't touch, so the entire codebase must be re-scanned. Returns: - True if full scan is needed (hash changed), False otherwise. + True if full scan is needed, False otherwise. """ - try: - result = subprocess.run( - [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], - capture_output=True, - check=False, - ) - # Exit 0 means hash changed (full scan needed) - return result.returncode == 0 - except Exception: # noqa: BLE001 - # If hash check fails, run full scan to be safe - return True + for file in changed_files(branch): + if file in CLANG_TIDY_GLOBAL_FILES: + return True + # Root-level sdkconfig.defaults and per-target sdkconfig.defaults. + if "/" not in file and file.startswith(SDKCONFIG_DEFAULTS_PREFIX): + return True + return False def should_run_clang_tidy(branch: str | None = None) -> bool: @@ -307,13 +306,12 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: Clang-tidy will run when ANY of the following conditions are met: - 1. Clang-tidy configuration changed - - The hash of .clang-tidy configuration file has changed - - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, - and relevant platformio.ini sections - - When configuration changes, a full scan is needed to ensure all code complies - with the new rules - - Detected by script/clang_tidy_hash.py --check returning exit code 0 + 1. A clang-tidy-relevant config file changed (full scan needed) + - Any file in CLANG_TIDY_GLOBAL_FILES (.clang-tidy, platformio.ini, + requirements_dev.txt, esphome/idf_component.yml) or a root-level + sdkconfig.defaults* file + - These affect clang-tidy results globally, so all code must be re-checked + to ensure it still complies 2. Any C++ source files changed - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc @@ -321,27 +319,14 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: - This ensures all C++ code is checked, including tests, examples, etc. - Examples: esphome/core/component.cpp, tests/custom/my_component.h - 3. The .clang-tidy.hash file itself changed - - This indicates the configuration has been updated and clang-tidy should run - - Ensures that PRs updating the clang-tidy configuration are properly validated - - If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure - code quality is maintained. - Args: branch: Branch to compare against. If None, uses default. Returns: True if clang-tidy should run, False otherwise. """ - # First check if clang-tidy configuration changed (full scan needed) - if _is_clang_tidy_full_scan(): - return True - - # Check if .clang-tidy.hash file itself was changed - # This handles the case where the hash was properly updated in the PR - files = changed_files(branch) - if ".clang-tidy.hash" in files: + # First check if a clang-tidy-relevant config file changed (full scan needed) + if _is_clang_tidy_full_scan(branch): return True return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) @@ -1276,9 +1261,9 @@ def main() -> None: # 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 + # Full scan needed if: a clang-tidy-relevant config file changed OR + # core files changed (is_core_change is forced True under --force-all) + is_full_scan = _is_clang_tidy_full_scan(args.branch) or is_core_change if is_full_scan: # Full scan checks all files - always use split mode for efficiency diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 194926a5df9..b5a9d8ebe9b 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -1,9 +1,7 @@ """Unit tests for script/clang_tidy_hash.py module.""" -import hashlib from pathlib import Path import sys -from unittest.mock import Mock, patch import pytest @@ -11,76 +9,45 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) import clang_tidy_hash # noqa: E402 +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402 -@pytest.mark.parametrize( - ("file_content", "expected"), - [ - ( - "clang-tidy==18.1.5 # via -r requirements_dev.in\n", - "clang-tidy==18.1.5 # via -r requirements_dev.in", - ), - ( - "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", - "clang-tidy==17.0.0", - ), - ( - "# comment\nclang-tidy==16.0.0 # some comment\n", - "clang-tidy==16.0.0 # some comment", - ), - ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), - ], -) -def test_get_clang_tidy_version_from_requirements( - file_content: str, expected: str +def _populate(repo_root: Path) -> None: + """Create every clang-tidy global file plus a base sdkconfig.defaults.""" + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"contents of {name}\n") + (repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n") + + +def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None: + """Same inputs must produce the same hash.""" + _populate(tmp_path) + assert clang_tidy_hash.calculate_clang_tidy_hash( + repo_root=tmp_path + ) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + +@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES) +def test_calculate_clang_tidy_hash_changes_with_each_global_file( + tmp_path: Path, filename: str ) -> None: - """Test extracting clang-tidy version from various file formats.""" - # Mock read_file_lines to return our test content - with patch("clang_tidy_hash.read_file_lines") as mock_read: - mock_read.return_value = file_content.splitlines(keepends=True) + """Editing any global file must change the hash.""" + _populate(tmp_path) + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + (tmp_path / filename).write_text("changed\n") + after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - assert result == expected - - -def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash from all configuration sources including sdkconfig.defaults.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation - expected_hasher = hashlib.sha256() - 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() - - result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash + assert after != before 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") - + _populate(tmp_path) before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) # Adding a per-target file must change the hash. @@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( 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" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files (without sdkconfig.defaults) - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation (no sdkconfig) - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hash = expected_hasher.hexdigest() - +def test_calculate_clang_tidy_hash_handles_missing_optional_files( + tmp_path: Path, +) -> None: + """Hash calculation must not fail when files are absent.""" + # Only .clang-tidy present; everything else missing. + (tmp_path / ".clang-tidy").write_text("Checks: '-*'\n") result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash - - -def test_read_stored_hash_exists(tmp_path: Path) -> None: - """Test reading hash when file exists.""" - stored_hash = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - hash_file.write_text(f"{stored_hash}\n") - - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result == stored_hash - - -def test_read_stored_hash_not_exists(tmp_path: Path) -> None: - """Test reading hash when file doesn't exist.""" - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result is None - - -def test_write_hash(tmp_path: Path) -> None: - """Test writing hash to file.""" - hash_value = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - - clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path) - - assert hash_file.exists() - assert hash_file.read_text() == hash_value.strip() + "\n" - - -@pytest.mark.parametrize( - ("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"), - [ - (["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed - (["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed - (["--check"], "abc123", None, False, 0), # No stored hash, scan needed - ( - ["--check"], - "abc123", - "abc123", - True, - 0, - ), # Hash file updated in PR, scan needed - ], -) -def test_main_check_mode( - args: list[str], - current_hash: str, - stored_hash: str | None, - hash_file_in_changed: bool, - expected_exit: int, -) -> None: - """Test main function in check mode.""" - changed = [".clang-tidy.hash"] if hash_file_in_changed else [] - - # Create a mock module that can be imported - mock_helpers = Mock() - mock_helpers.changed_files = Mock(return_value=changed) - - with ( - patch("sys.argv", ["clang_tidy_hash.py"] + args), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch.dict("sys.modules", {"helpers": mock_helpers}), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == expected_exit - - -def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in update mode.""" - current_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - ): - clang_tidy_hash.main() - - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert f"Hash updated: {current_hash}" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hash changed, should update - ("abc123", None), # No stored hash, should update - ], -) -def test_main_update_if_changed_mode_update( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in update-if-changed mode when update is needed.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert "Clang-tidy hash updated" in captured.out - - -def test_main_update_if_changed_mode_no_update( - capsys: pytest.CaptureFixture[str], -) -> None: - """Test main function in update-if-changed mode when no update is needed.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_not_called() - captured = capsys.readouterr() - assert "Clang-tidy hash unchanged" in captured.out - - -def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in verify mode when verification passes.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - captured = capsys.readouterr() - assert "Hash verification passed" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hashes differ, verification fails - ("abc123", None), # No stored hash, verification fails - ], -) -def test_main_verify_mode_failure( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in verify mode when verification fails.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ERROR: Clang-tidy configuration has changed" in captured.out - - -def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in default mode (no arguments).""" - current_hash = "abc123" - stored_hash = "def456" - - with ( - patch("sys.argv", ["clang_tidy_hash.py"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - - captured = capsys.readouterr() - assert f"Current hash: {current_hash}" in captured.out - assert f"Stored hash: {stored_hash}" in captured.out - assert "Match: False" in captured.out - - -def test_read_file_lines(tmp_path: Path) -> None: - """Test read_file_lines helper function.""" - test_file = tmp_path / "test.txt" - test_content = "line1\nline2\nline3\n" - test_file.write_text(test_content) - - result = clang_tidy_hash.read_file_lines(test_file) - - assert result == ["line1\n", "line2\n", "line3\n"] + assert len(result) == 64 # sha256 hexdigest length def test_read_file_bytes(tmp_path: Path) -> None: @@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None: result = clang_tidy_hash.read_file_bytes(test_file) assert result == test_content - - -def test_write_file_content(tmp_path: Path) -> None: - """Test write_file_content helper function.""" - test_file = tmp_path / "test.txt" - test_content = "test content" - - clang_tidy_hash.write_file_content(test_file, test_content) - - assert test_file.read_text() == test_content - - -@pytest.mark.parametrize( - ("line", "expected"), - [ - ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), - ( - "clang-tidy==18.1.5 # comment", - ("clang-tidy", "clang-tidy==18.1.5 # comment"), - ), - ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), - ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), - ("# just a comment", None), - ("", None), - (" ", None), - ("invalid line without version", None), - ], -) -def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: - """Test parsing individual requirement lines.""" - result = clang_tidy_hash.parse_requirement_line(line) - assert result == expected diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9defcacac7..f8f359ee22b 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,7 @@ import importlib.util import json from pathlib import Path import sys -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch import pytest @@ -653,52 +653,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: @pytest.mark.parametrize( - ("check_returncode", "changed_files", "expected_result"), + ("changed_files", "expected_result"), [ - (0, [], True), # Hash changed - need full scan - (1, ["esphome/core.cpp"], True), # C++ file changed - (1, ["README.md"], False), # No C++ files changed - (1, [".clang-tidy.hash"], True), # Hash file itself changed - (1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed + ([], False), # Nothing changed + (["esphome/core.cpp"], True), # C++ file changed + (["README.md"], False), # No C++ files changed + ([".clang-tidy"], True), # clang-tidy config changed - full scan + (["platformio.ini"], True), # build config changed - full scan + (["requirements_dev.txt"], True), # clang-tidy version source changed + (["sdkconfig.defaults"], True), # sdkconfig changed - full scan + (["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed + (["esphome/idf_component.yml"], True), # idf managed deps changed + (["platformio.ini", "README.md"], True), # config + non-C++ ], ) def test_should_run_clang_tidy( - check_returncode: int, changed_files: list[str], expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with ( - patch.object(determine_jobs, "changed_files", return_value=changed_files), - patch("subprocess.run") as mock_run, - ): - # Test with hash check returning specific code - mock_run.return_value = Mock(returncode=check_returncode) + with patch.object(determine_jobs, "changed_files", return_value=changed_files): result = determine_jobs.should_run_clang_tidy() assert result == expected_result -def test_should_run_clang_tidy_hash_check_exception() -> None: - """Test should_run_clang_tidy when hash check fails with exception.""" - # When hash check fails, clang-tidy should run as a safety measure - with ( - patch.object(determine_jobs, "changed_files", return_value=["README.md"]), - patch("subprocess.run", side_effect=Exception("Hash check failed")), - ): - result = determine_jobs.should_run_clang_tidy() - assert result is True # Fail safe - run clang-tidy - - def test_should_run_clang_tidy_with_branch() -> None: """Test should_run_clang_tidy with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=1) # Hash unchanged - determine_jobs.should_run_clang_tidy("release") - # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check - assert mock_changed.call_count == 2 - mock_changed.assert_has_calls([call("release"), call("release")]) + determine_jobs.should_run_clang_tidy("release") + # changed_files is queried against the given branch by both the + # config-file full-scan check and the C++ extension check. + mock_changed.assert_called_with("release") @pytest.mark.parametrize( From c9095841ae74cc59093100907b19f29aa3bfab5b Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Thu, 18 Jun 2026 04:16:28 +0300 Subject: [PATCH 197/219] [ufm01] Add UFM-01 ultrasonic flow meter component (#16582) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ufm01/__init__.py | 40 +++ esphome/components/ufm01/binary_sensor.py | 52 ++++ esphome/components/ufm01/sensor.py | 63 +++++ esphome/components/ufm01/ufm01.cpp | 234 ++++++++++++++++++ esphome/components/ufm01/ufm01.h | 57 +++++ tests/components/ufm01/common.yaml | 30 +++ tests/components/ufm01/test.esp32-idf.yaml | 4 + tests/components/ufm01/test.esp8266-ard.yaml | 4 + tests/components/ufm01/test.rp2040-ard.yaml | 4 + .../common/uart_2400_even/esp32-idf.yaml | 12 + .../common/uart_2400_even/esp8266-ard.yaml | 12 + .../common/uart_2400_even/rp2040-ard.yaml | 12 + 13 files changed, 525 insertions(+) create mode 100644 esphome/components/ufm01/__init__.py create mode 100644 esphome/components/ufm01/binary_sensor.py create mode 100644 esphome/components/ufm01/sensor.py create mode 100644 esphome/components/ufm01/ufm01.cpp create mode 100644 esphome/components/ufm01/ufm01.h create mode 100644 tests/components/ufm01/common.yaml create mode 100644 tests/components/ufm01/test.esp32-idf.yaml create mode 100644 tests/components/ufm01/test.esp8266-ard.yaml create mode 100644 tests/components/ufm01/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e52..3265627c030 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -561,6 +561,7 @@ esphome/components/uart/packet_transport/* @clydebarrow esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli +esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py new file mode 100644 index 00000000000..51cf3cfd91e --- /dev/null +++ b/esphome/components/ufm01/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@ljungqvist"] + +MULTI_CONF = True + +DEPENDENCIES = ["uart"] + +ufm01_ns = cg.esphome_ns.namespace("ufm01") +UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component) + +CONF_UFM01_ID = "ufm01_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UFM01Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ufm01", + require_tx=True, + require_rx=True, + baud_rate=2400, + parity="EVEN", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py new file mode 100644 index 00000000000..92ae585d962 --- /dev/null +++ b/esphome/components/ufm01/binary_sensor.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_UFC_CHIP_ERROR = "ufc_chip_error" +CONF_FLOW_DIRECTION_WRONG = "flow_direction_wrong" +CONF_EMPTY_TUBE = "empty_tube" +CONF_FLOW_RATE_OUT_OF_RANGE = "flow_rate_out_of_range" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_UFC_CHIP_ERROR): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, device_class=DEVICE_CLASS_PROBLEM + ), + cv.Optional(CONF_FLOW_DIRECTION_WRONG): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_EMPTY_TUBE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_FLOW_RATE_OUT_OF_RANGE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): + sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) + cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) + + if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): + sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) + cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) + + if empty_tube_config := config.get(CONF_EMPTY_TUBE): + sens = await binary_sensor.new_binary_sensor(empty_tube_config) + cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) + + if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): + sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) + cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py new file mode 100644 index 00000000000..4dcd7ceebe7 --- /dev/null +++ b/esphome/components/ufm01/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FLOW, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + DEVICE_CLASS_WATER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_LITRE, +) + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_ACCUMULATED_FLOW = "accumulated_flow" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_ACCUMULATED_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_LITRE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_WATER, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, + accuracy_decimals=5, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:waves-arrow-right", + ), + 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, + icon="mdi:thermometer-water", + ), + } +) + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if CONF_ACCUMULATED_FLOW in config: + sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) + cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) + + if CONF_FLOW in config: + sens = await sensor.new_sensor(config[CONF_FLOW]) + cg.add(ufm01_component.set_flow_sensor(sens)) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(ufm01_component.set_temperature_sensor(sens)) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp new file mode 100644 index 00000000000..1380c342841 --- /dev/null +++ b/esphome/components/ufm01/ufm01.cpp @@ -0,0 +1,234 @@ +#include "ufm01.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ufm01 { + +static const char *const TAG = "ufm01"; + +static constexpr uint8_t COMMAND_ACK = 0xE5; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; + +static constexpr float L_PER_M3 = 1000.0f; +static constexpr float M3_PER_L = 1.0f / L_PER_M3; + +static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; + +// Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_CHECKSUM_INDEX = 30; +static constexpr size_t FRAME_STOP_INDEX = 31; +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; +static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; +static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; + +// Measurement decoding +static constexpr uint8_t FRAME_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t ACC_FLOW_M3_FLAG = 0x1A; +static constexpr uint8_t FRAME_FLOW_SIGN_INDEX = 20; +static constexpr uint8_t FLOW_NEGATIVE_SIGN = 0x80; + +// Status bytes (datasheet ST1 / ST2) +static constexpr uint8_t FRAME_ST1_INDEX = 28; +static constexpr uint8_t FRAME_ST2_INDEX = 29; +static constexpr uint8_t ST1_EMPTY_TUBE_MASK = 0x20; +static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; +static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; +static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; + +static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } + +static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { + if (data[index] == expected) + return true; + ESP_LOGW(TAG, "%s (byte %zu) - expected 0x%02X, but was 0x%02X", name, index, expected, data[index]); + return false; +} + +static bool validate_data(uint8_t data[FRAME_SIZE]) { + uint8_t sum = 0; + for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) + sum += data[i]; + return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && + check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && + check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && + check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") && + check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") && + check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") && + check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); +} + +static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * + (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); +} + +static float read_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * + (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + + to_float(data[16]) * 0.01f) * + M3_PER_L; +} + +static void log_hex(const uint8_t *data, size_t len) { + char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); +} + +static float read_temperature(uint8_t data[FRAME_SIZE]) { + // happens sometimes before getting a real reading + if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { + return NAN; + } + return to_float(data[27]) * 100.0f + to_float(data[26]) + to_float(data[25]) * 0.01f; +} + +static bool read_ufc_chip_error(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_UFC_ERROR_MASK; } + +static bool read_flow_direction_wrong(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_DIRECTION_WRONG_MASK; +} + +static bool read_empty_tube(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST1_INDEX] & ST1_EMPTY_TUBE_MASK; } + +static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->write_array(command); + this->flush(); + const uint32_t start = millis(); + while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { + if (this->available()) { + uint8_t byte; + if (this->read_byte(&byte)) { + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + } + delay(1); + } + return false; +} + +bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } + +bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } + +bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } + +float UFM01Component::get_setup_priority() const { return setup_priority::IO; } + +void UFM01Component::setup() { + ESP_LOGI(TAG, "Setting up UFM-01..."); + if (!this->set_active_mode_()) { + ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); + this->mark_failed(); + } +} + +void UFM01Component::dump_config() { + ESP_LOGCONFIG(TAG, "UFM-01:"); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Accumulated Flow", this->accumulated_flow_sensor_); + LOG_SENSOR(" ", "Flow", this->flow_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "UFC Chip Error", this->ufc_chip_error_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Direction Wrong", this->flow_direction_wrong_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); +#endif + this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); + if (this->is_failed()) { + ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); + } +} + +void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { + bool empty_tube = read_empty_tube(data); +#ifdef USE_BINARY_SENSOR + if (this->ufc_chip_error_binary_sensor_ != nullptr) + this->ufc_chip_error_binary_sensor_->publish_state(read_ufc_chip_error(data)); + if (this->flow_direction_wrong_binary_sensor_ != nullptr) + this->flow_direction_wrong_binary_sensor_->publish_state(read_flow_direction_wrong(data)); + if (this->empty_tube_binary_sensor_ != nullptr) + this->empty_tube_binary_sensor_->publish_state(empty_tube); + if (this->flow_rate_out_of_range_binary_sensor_ != nullptr) + this->flow_rate_out_of_range_binary_sensor_->publish_state(read_flow_rate_out_of_range(data)); +#endif + +#ifdef USE_SENSOR + // Total volume remains valid when the tube is dry; flow and temperature are not. + if (this->accumulated_flow_sensor_ != nullptr) + this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); + + if (empty_tube) { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + } else { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(read_flow(data)); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(read_temperature(data)); + } +#endif +} + +void UFM01Component::loop() { + // Drain the UART buffer each loop, reading one byte at a time into the frame + while (this->available()) { + if (!this->read_byte(&this->data_[this->read_index_])) { + ESP_LOGW(TAG, "unable to read byte"); + this->read_index_ = 0; + continue; + } + if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || + (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { + ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + this->read_index_ = 0; + continue; + } + if (++this->read_index_ < static_cast(FRAME_SIZE)) + continue; + + // Full frame received + if (validate_data(this->data_)) { + this->on_data_(this->data_); + this->read_index_ = 0; + continue; + } + + // Invalid frame: try to resync on the next start marker within the buffer + log_hex(this->data_, sizeof(this->data_)); + ESP_LOGE(TAG, "unable to read data"); + for (int32_t i = 2; + i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { + if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { + for (int32_t j = i; j < static_cast(FRAME_SIZE); ++j) + this->data_[j - i] = this->data_[j]; + this->read_index_ = static_cast(FRAME_SIZE) - i; + } + } + if (this->read_index_ == static_cast(FRAME_SIZE)) + this->read_index_ = 0; + } +} + +} // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h new file mode 100644 index 00000000000..e759de91690 --- /dev/null +++ b/esphome/components/ufm01/ufm01.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +// component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf + +namespace esphome::ufm01 { + +static constexpr size_t FRAME_SIZE = 32; + +class UFM01Component : public uart::UARTDevice, public Component { +#ifdef USE_SENSOR + SUB_SENSOR(accumulated_flow) + SUB_SENSOR(flow) + SUB_SENSOR(temperature) +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(ufc_chip_error) + SUB_BINARY_SENSOR(flow_direction_wrong) + SUB_BINARY_SENSOR(empty_tube) + SUB_BINARY_SENSOR(flow_rate_out_of_range) +#endif + + public: + void setup() override; + + void dump_config() override; + + void loop() override; + + float get_setup_priority() const override; + + protected: + bool clear_accumulated_flow_(); + bool set_active_mode_(); + bool reset_device_(); + + private: + bool send_command_(const std::array &command); + + int32_t read_index_ = 0; + uint8_t data_[FRAME_SIZE]; + void on_data_(uint8_t data[FRAME_SIZE]); +}; + +} // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml new file mode 100644 index 00000000000..c818dc29651 --- /dev/null +++ b/tests/components/ufm01/common.yaml @@ -0,0 +1,30 @@ +ufm01: + id: ufm01_component + uart_id: uart_bus + +sensor: + - platform: ufm01 + accumulated_flow: + id: accumulated_flow + name: "Accumulated flow" + flow: + id: flow + name: "Flow" + temperature: + id: temperature + name: "Temperature" + +binary_sensor: + - platform: ufm01 + ufc_chip_error: + id: ufc_chip_error + name: "UFC chip error" + flow_direction_wrong: + id: flow_direction_wrong + name: "Flow direction wrong" + empty_tube: + id: empty_tube + name: "Empty tube" + flow_rate_out_of_range: + id: flow_rate_out_of_range + name: "Flow rate out of range" diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml new file mode 100644 index 00000000000..34041cc2237 --- /dev/null +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml new file mode 100644 index 00000000000..195f4b41b59 --- /dev/null +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml new file mode 100644 index 00000000000..13b3284fe30 --- /dev/null +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml new file mode 100644 index 00000000000..92a65c463e7 --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml new file mode 100644 index 00000000000..00333867dbe --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml new file mode 100644 index 00000000000..c915e7846dd --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN From e3f164fff20edb2e3aa7e4b9a3a4330d4c41fbec Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 18 Jun 2026 03:17:07 +0200 Subject: [PATCH 198/219] [nrf52] add support for native builds (#16898) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/build_gen/espidf.py | 12 +-- esphome/components/nrf52/__init__.py | 98 +++++++++++++++++- esphome/components/nrf52/framework.py | 114 ++++++++++++++++++--- esphome/components/nrf52/requirements.txt | 3 + esphome/framework_helpers.py | 19 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 +++++++++ tests/unit_tests/test_framework_helpers.py | 83 +++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 33 +++--- 8 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 esphome/components/nrf52/requirements.txt diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9e11d785c06..dec6ea04deb 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,7 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE +from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -84,12 +85,7 @@ def get_project_cmakelists(minimal: bool = False) -> str: # esphome__micro-mp3) rather than just src/. Required so suppressions # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # third-party components we don't author. - project_compile_opts = [ - flag - for flag in sorted(CORE.build_flags) - if flag.startswith("-D") - or (flag.startswith("-W") and not flag.startswith("-Wl,")) - ] + project_compile_opts = get_project_compile_flags() extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' for flag in project_compile_opts @@ -188,8 +184,8 @@ def get_component_cmakelists() -> str: # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. - link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" + link_opts = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b267..d87318b03db 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -52,6 +52,11 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +68,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install +from .framework import check_and_install, get_build_env, get_build_paths # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """ def set_core_data(config: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def _resolve_toolchain(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return config + + def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" @@ -147,6 +155,12 @@ BOOTLOADERS = [ ] +def _validate_toolchain(value) -> Toolchain: + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) + ) + + def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() @@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> None: + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + "find_package(Zephyr REQUIRED)", + "", + f"project({CORE.name})", + "", + 'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")', + "", + "target_sources(app PRIVATE ${APP_SOURCES})", + 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', + ] + + if compile_flags: + lines += [ + "", + "target_compile_options(app PRIVATE", + *[f' "{flag}"' for flag in compile_flags], + ")", + ] + + if link_flags: + lines += [ + "", + "zephyr_ld_options(", + *[f' "{flag}"' for flag in link_flags], + ")", + ] + + write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool: "Supported toolchains are 'platformio' and 'sdk-nrf'." ) check_and_install() - raise EsphomeError("Native build for nRF52 is not implemented yet") + + paths = get_build_paths() + env = get_build_env() + + _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + ] + + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 native build failed") + + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7edc..a35ba3ef85d 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -18,7 +18,7 @@ from esphome.framework_helpers import ( _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" _TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( @@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( ) ) +# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and +# host tools (dtc etc.) required by the Zephyr cmake build system. +SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_MINIMAL_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}", + ) +) + def _get_tools_path() -> Path: return CORE.data_dir / "sdk-nrf" @@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path: def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return _get_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return _get_tools_path() / "toolchains" / version # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. @@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]: return sysname, machine, extension -def check_and_install() -> None: +def _get_version_str() -> str: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + + +def get_build_paths() -> dict: + version = _get_version_str() + return { + "python_executable": get_python_env_executable_path( + _get_python_env_path(version), "python" + ), + "framework_path": _get_framework_path(version), + } + + +def get_build_env() -> dict: + version = _get_version_str() + venv_bin_dir = get_python_env_executable_path( + _get_python_env_path(version), "python" + ).parent + env = os.environ.copy() + env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") + env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + return env + + +def check_and_install() -> None: + version = _get_version_str() 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() + install_venv = ( + not sentinel.exists() + or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") - create_venv(python_env_path, msg=f"{version}") + create_venv(python_env_path, msg=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}"] + _LOGGER.info("Installing requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + ] if not run_command_ok(cmd): - raise EsphomeError(f"Install west for {version} Python environment failure") + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) sentinel.touch() framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" - if install_venv or not sentinel.exists(): + zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + if not sentinel.exists() or not zephyr_reqs.exists(): rmdir(framework_path, msg=f"Clean up {version} framework environment") _LOGGER.info("Initializing nRF Connect SDK %s ...", version) cmd = [ @@ -128,7 +176,7 @@ def check_and_install() -> None: "-m", "https://github.com/nrfconnect/sdk-nrf", "--mr", - f"{version}", + version, str(framework_path), ] if not run_command_ok(cmd): @@ -146,17 +194,47 @@ def check_and_install() -> None: raise EsphomeError(f"Can't update nRF Connect SDK {version}") sentinel.touch() + zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" + if ( + install_venv + or not zephyr_sentinel.exists() + or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime + ): + _LOGGER.info("Installing Zephyr requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(zephyr_reqs), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Install Zephyr requirements for {version} failure") + zephyr_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" ) + sysname, machine, extension = _get_toolchain_platform_info() + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_MINIMAL_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") 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, { @@ -167,5 +245,9 @@ def check_and_install() -> None: }, tmp.file, ) - archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 00000000000..250d3a29cfe --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,3 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c3..6bf389240b0 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -20,6 +20,25 @@ PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +def get_project_link_flags() -> list[str]: + """Return the sorted -Wl, linker flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,")) + + +def get_project_compile_flags() -> list[str]: + """Return the sorted -D and -W (non-linker) flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a5c2719f426..0f4444f719b 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_component_cmakelists_no_link_flags() -> None: + """With no -Wl, flags the target_link_options block is emitted with an empty body.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content + + +def test_get_component_cmakelists_single_link_flag() -> None: + """A single -Wl, flag appears indented inside target_link_options.""" + CORE.build_flags = {"-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)" + in content + ) + + +def test_get_component_cmakelists_multiple_link_flags_sorted() -> None: + """Multiple -Wl, flags are sorted and joined with the four-space indent.""" + CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + expected = ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n" + " -Wl,--gc-sections\n" + " -Wl,-Map=out.map\n" + " -Wl,-z,noexecstack\n" + ")" + ) + assert expected in content + + +def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None: + """-D and -W (non-linker) flags must not appear in target_link_options.""" + CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "-DFOO" not in content.split("target_link_options")[1] + assert "-Wall" not in content.split("target_link_options")[1] + assert "-Wl,--gc-sections" in content + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c01..f6e783b5e82 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,8 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -952,3 +954,84 @@ class TestSevenZipExtractAll: out.mkdir() archive_extract_all(archive, out) assert (out / "hello.txt").exists() + + +# --------------------------------------------------------------------------- +# get_project_compile_flags / get_project_link_flags +# --------------------------------------------------------------------------- + + +def _make_core(flags: set[str]): + core = MagicMock() + core.build_flags = flags + return core + + +class TestGetProjectCompileFlags: + def test_returns_define_flags(self) -> None: + with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})): + assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"] + + def test_returns_warning_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wno-error", "-Wall"}), + ): + assert get_project_compile_flags() == ["-Wall", "-Wno-error"] + + def test_excludes_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_excludes_other_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-O2", "-std=gnu++20", "-DFOO"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_compile_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}), + ): + result = get_project_compile_flags() + assert result == sorted(result) + + +class TestGetProjectLinkFlags: + def test_returns_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_link_flags() == [ + "-Wl,--gc-sections", + "-Wl,-Map=output.map", + ] + + def test_excludes_compile_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}), + ): + assert get_project_link_flags() == ["-Wl,--gc-sections"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_link_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}), + ): + result = get_project_link_flags() + assert result == sorted(result) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb9..04c712f0b73 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) + zephyr_scripts = framework / "zephyr" / "scripts" + zephyr_scripts.mkdir(parents=True, exist_ok=True) + (zephyr_scripts / "requirements.txt").touch() return SimpleNamespace( python_env=python_env, framework=framework, @@ -102,6 +105,7 @@ class TestCheckAndInstall: ) -> None: """All three sentinels present → nothing downloaded or compiled.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +125,13 @@ class TestCheckAndInstall: 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() + # pip install requirements, west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 4 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists() assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists() @@ -140,9 +146,10 @@ class TestCheckAndInstall: 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() + # west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 3 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 def test_toolchain_only_missing( self, @@ -151,24 +158,26 @@ class TestCheckAndInstall: ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_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() + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 - def test_west_install_failure_raises( + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, mock_nrf52_ops: SimpleNamespace, ) -> None: - """Failing pip install west raises EsphomeError.""" + """Failing pip install -r requirements.txt raises EsphomeError.""" mock_nrf52_ops.run_command_ok.return_value = False - with pytest.raises(EsphomeError, match="Install west"): + with pytest.raises(EsphomeError, match="Install requirements"): check_and_install() def test_framework_init_failure_raises( From c214a8ce799cfa483eaecbf8cad4dc3d3ceaaf44 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:21:00 +1200 Subject: [PATCH 199/219] [core] Add generic component alias infrastructure (#16826) --- esphome/config.py | 102 +++++ esphome/loader.py | 305 +++++++++++++++ tests/unit_tests/test_loader.py | 663 +++++++++++++++++++++++++++++++- 3 files changed, 1068 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad5..33e687137f2 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool: return path[: len(other)] == other +# CORE.data key for the per-alias "already warned this run" dedupe set. +# Cleared between runs because CORE.data is reset; one warning per alias +# per `esphome config|compile|run` invocation is the desired UX. +_ALIAS_WARNED_KEY = "_component_aliases_warned" + + +def _resolve_component_aliases(config: dict[str, Any]) -> None: + """Rewrite legacy top-level keys to their canonical names, in place. + + Looks up each top-level key against the component-alias map built by + :mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a + matching alias is found, the key is moved to its canonical name and a + one-shot deprecation warning is logged (per alias, per run — deduped + via ``CORE.data``). + + Ambiguous configurations raise ``cv.Invalid`` rather than silently + keeping one entry — that would hide a real misconfiguration. Two cases + are rejected: the canonical key together with one of its deprecated + aliases, and two or more different aliases of the same canonical + component. + + The rest of the validator chain (dependency resolution, schema + validation, codegen) sees only canonical names, so component + `DEPENDENCIES = [""]` works regardless of which spelling + the user typed. + """ + alias_meta_map = loader.get_alias_metadata() + if not alias_meta_map: + return + + # Group every legacy alias key present in the config by the canonical + # component it resolves to, preserving config order within each group. + legacy_by_canonical: dict[str, list[str]] = {} + for key in config: + meta = alias_meta_map.get(key) + if meta is not None: + legacy_by_canonical.setdefault(meta.canonical, []).append(key) + + if not legacy_by_canonical: + return + + # Reject ambiguous configurations up front — checking before rewriting + # means a conflict is caught regardless of key order. + for canonical, legacies in legacy_by_canonical.items(): + if canonical in config: + # The canonical key and (at least) one deprecated alias are both + # present. + raise vol.Invalid( + f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') " + f"and '{canonical}:' are present in the configuration. Remove " + f"the deprecated '{legacies[0]}:' key.", + path=[legacies[0]], + ) + if len(legacies) > 1: + # Several different deprecated aliases of the same component. + listed = ", ".join(f"'{alias}:'" for alias in legacies) + raise vol.Invalid( + f"Multiple deprecated aliases of '{canonical}:' are present " + f"({listed}). Use only '{canonical}:'.", + path=[legacies[0]], + ) + + warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set()) + + # Rebuild in place so each canonical key keeps the legacy key's original + # position — top-level key order matters for some downstream passes + # (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)` + # would instead move the renamed key to the end. + rewritten: dict[str, Any] = {} + for key, value in config.items(): + meta = alias_meta_map.get(key) + if meta is None: + rewritten[key] = value + continue + rewritten[meta.canonical] = value + if key not in warned: + warned.add(key) + removal = ( + f" Removed in {meta.removal_version}." if meta.removal_version else "" + ) + _LOGGER.warning( + "The '%s:' top-level key is deprecated; rename it to '%s:'.%s", + key, + meta.canonical, + removal, + ) + config.clear() + config.update(rewritten) + + @functools.total_ordering class _ValidationStepTask: def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): @@ -1048,6 +1138,18 @@ def validate_config( substitutions = config.pop(CONF_SUBSTITUTIONS, None) CORE.raw_config = config + # 1.15. Resolve component aliases so legacy top-level keys + # (`rp2040:`, …) route to their canonical component before any + # downstream pass touches the config. Logs a deprecation warning + # per alias; mutates `config` in place. Errors here surface as + # plain config errors and abort further validation. + try: + _resolve_component_aliases(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1a..a9287abf866 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -101,6 +101,27 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def aliases(self) -> list[str]: + """Legacy names that should transparently route to this component. + + See the :func:`_build_alias_map` documentation for how aliases are + discovered (AST scan, no execution) and registered both for the YAML + loader (top-level key rename in :mod:`esphome.config`) and for + Python imports (``sys.meta_path`` finder, below). + """ + return getattr(self.module, "ALIASES", []) + + @property + def alias_removal_version(self) -> str | None: + """Optional ESPHome version when the alias warning becomes a hard error. + + Surfaced in the deprecation warning emitted by the YAML pre-pass so + users know how long they have to migrate. ``None`` means the warning + does not mention a specific version. + """ + return getattr(self.module, "ALIAS_REMOVAL_VERSION", None) + @property def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: _COMPONENT_CACHE[domain] = manif return manif + # If `domain` is the legacy name of a renamed component, redirect to the + # canonical module so the rest of the loader (and every caller of + # `get_component(legacy)`) transparently sees the new component. + alias_map = _get_alias_map() + if domain in alias_map: + canonical = alias_map[domain] + manif = _lookup_module(canonical, exception) + if manif is not None: + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non code should never call this. """ _COMPONENT_CACHE[domain] = manifest + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two +# integrations are then wired up automatically: +# +# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) +# intercepts ``esphome.components.``/``....`` +# imports and resolves them against the canonical component so external +# custom components that still import from the old path keep working. +# +# 2. **YAML loader** — ``_lookup_module`` consults the alias map so +# ``get_component("legacy")`` returns the canonical manifest. The +# ``esphome.config`` pre-pass uses the same map to rewrite legacy +# top-level keys in the user's config (with a deprecation warning) so +# dependency checks, schema validation and codegen all see only the +# canonical name. +# +# Both lookups are populated by ``_build_alias_map``, which **AST-parses** +# every component's ``__init__.py`` rather than importing it. That keeps the +# cost low: scanning ~400 components on disk takes ~5 ms instead of the +# multi-second cost of executing every component's import side-effects. + + +_ALIAS_MAP_CACHE: dict[str, str] | None = None +_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None + + +@dataclass(frozen=True) +class AliasMeta: + """Metadata for a single deprecated alias entry. + + Used by the YAML pre-pass in :mod:`esphome.config` to produce a + deprecation warning citing the canonical name and (optionally) the + removal version declared by the canonical component. + """ + + canonical: str + removal_version: str | None + + +def _ensure_alias_caches() -> None: + """Populate both alias caches from a single directory scan. + + ``_build_alias_map`` returns both maps together, so building them in one + shot avoids scanning every component's ``__init__.py`` twice when a run + needs both the canonical map (loader) and the metadata map (config + pre-pass). + """ + global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE + if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: + _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() + + +def _get_alias_map() -> dict[str, str]: + """Return the legacy-name → canonical-name map, building it lazily.""" + _ensure_alias_caches() + return _ALIAS_MAP_CACHE + + +def get_alias_metadata() -> dict[str, AliasMeta]: + """Return the legacy-name → :class:`AliasMeta` map (cached). + + Used by the YAML pre-pass to format a per-alias deprecation warning. + """ + _ensure_alias_caches() + return _ALIAS_META_CACHE + + +def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]: + """Scan every core component dir for ``ALIASES`` declarations. + + Uses :mod:`ast` to read each component's ``__init__.py`` without + executing it — component import side-effects (logger setup, + namespace registration, etc.) shouldn't run just because we're + enumerating aliases. + + Raises if the same alias is claimed by two canonical components, since + silently picking one would cause non-deterministic routing depending on + directory-iteration order. Also raises if an alias shadows an existing + component package: that would hijack a live component domain and, in the + self-alias case (alias == canonical), send ``_lookup_module`` into + infinite recursion redirecting a domain to itself. + """ + import ast + + alias_to_canonical: dict[str, str] = {} + alias_to_meta: dict[str, AliasMeta] = {} + + if not CORE_COMPONENTS_PATH.is_dir(): + return alias_to_canonical, alias_to_meta + + for child in sorted(CORE_COMPONENTS_PATH.iterdir()): + if not child.is_dir(): + continue + init = child / "__init__.py" + if not init.is_file(): + continue + aliases, removal_version = _read_aliases(init, ast) + if not aliases: + continue + canonical = child.name + for alias in aliases: + if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file(): + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' (declared by '{canonical}') " + "shadows an existing component package of the same name. " + "An alias may only name a component that no longer exists." + ) + if alias in alias_to_canonical: + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' is declared by both " + f"'{alias_to_canonical[alias]}' and '{canonical}'. " + "Each alias must map to exactly one canonical component." + ) + alias_to_canonical[alias] = canonical + alias_to_meta[alias] = AliasMeta( + canonical=canonical, removal_version=removal_version + ) + return alias_to_canonical, alias_to_meta + + +def _read_aliases( + init_path: Path, ast_module: ModuleType +) -> tuple[list[str], str | None]: + """Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component + ``__init__.py`` via AST parsing. + + Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."`` + forms — anything more dynamic (function call, conditional, etc.) is + silently ignored. Components should keep their alias declarations + static so this scanner can see them. + """ + try: + source = init_path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.warning( + "Could not read %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + # Cheap substring pre-filter: almost no component declares ALIASES, and + # parsing every component __init__.py with ast is comparatively expensive. + # Skip the parse entirely unless the token appears in the file at all. + if "ALIASES" not in source: + return [], None + + try: + tree = ast_module.parse(source) + except SyntaxError as err: + _LOGGER.warning( + "Could not parse %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + aliases: list[str] = [] + removal_version: str | None = None + + for node in tree.body: + if not isinstance(node, ast_module.Assign): + continue + for target in node.targets: + if not isinstance(target, ast_module.Name): + continue + if target.id == "ALIASES" and isinstance(node.value, ast_module.List): + aliases.extend( + elt.value + for elt in node.value.elts + if isinstance(elt, ast_module.Constant) + and isinstance(elt.value, str) + ) + elif ( + target.id == "ALIAS_REMOVAL_VERSION" + and isinstance(node.value, ast_module.Constant) + and isinstance(node.value.value, str) + ): + removal_version = node.value.value + return aliases, removal_version + + +class _AliasFinder(importlib.abc.MetaPathFinder): + """``sys.meta_path`` finder that resolves legacy-component imports. + + Routes ``esphome.components.[.]`` to the canonical + component's module/submodule of the same name, so external code that + still imports ``from esphome.components.rp2040 import boards`` keeps + working without the canonical component having to maintain a shim + package on disk. + + The finder caches the resolved module in ``sys.modules`` under the + legacy name on first lookup, so subsequent imports hit the cache and + skip this finder entirely. + """ + + _PREFIX = "esphome.components." + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if not fullname.startswith(self._PREFIX): + return None + # Anything matching the ``esphome.components.`` prefix splits into at + # least three parts, so ``parts[2]`` (the domain) always exists. + parts = fullname.split(".") + domain = parts[2] + alias_map = _get_alias_map() + if domain not in alias_map: + return None + + parts[2] = alias_map[domain] + canonical_fullname = ".".join(parts) + try: + canonical_module = importlib.import_module(canonical_fullname) + except ModuleNotFoundError as err: + # Only treat a missing *canonical target* as "no alias to + # resolve" (let the normal import machinery report it). If some + # other module is missing, the canonical exists but failed to + # import one of its own dependencies — surface that real error + # rather than masking it as an unresolved alias. + if err.name == canonical_fullname: + return None + raise + # Do NOT pre-populate ``sys.modules[fullname]`` here. Python's + # ``_find_spec`` (in importlib._bootstrap) has an optimization that + # detects ``name in sys.modules`` after a finder returns and prefers + # ``sys.modules[name].__spec__`` over the finder's spec — for an + # alias, that's the canonical module's own SourceFileLoader spec, + # which Python then *re-loads*, defeating the aliasing. Letting + # ``_load_unlocked`` populate sys.modules itself (via our + # ``_AliasLoader.create_module``) sidesteps that branch. + return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module)) + + +class _AliasLoader(importlib.abc.Loader): + """No-op loader that returns the already-resolved canonical module. + + :class:`_AliasFinder` populates ``sys.modules`` itself; this loader + just satisfies the :mod:`importlib` protocol so Python doesn't try to + re-execute the module. + """ + + def __init__(self, module: ModuleType) -> None: + self._module = module + + def create_module(self, spec): # noqa: ARG002 + return self._module + + def exec_module(self, module): # noqa: ARG002 + # Nothing to execute — the canonical module is already initialized. + return None + + +# Register once at module load. Idempotent: re-installing the finder on +# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op +# because we check for an existing instance first. +def _install_alias_finder() -> None: + for entry in sys.meta_path: + if isinstance(entry, _AliasFinder): + return + sys.meta_path.append(_AliasFinder()) + + +_install_alias_finder() diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a06..42e5203a737 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,28 @@ """Unit tests for esphome.loader module.""" -from unittest.mock import MagicMock, patch +import ast +import logging +from pathlib import Path +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, Mock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest +import voluptuous as vol + +from esphome import config as esphome_config, config_validation as cv +from esphome.core import CORE +import esphome.loader as loader_mod +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub names = [r.resource for r in manifest.resources] assert names == ["wake/wake_freertos.cpp"] + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# These tests pin down the substrate behind `ALIASES = [...]` on component +# `__init__.py` files: the AST scanner, the resulting global alias map, the +# Python-import `sys.meta_path` finder, the `get_component` integration, and +# the YAML pre-pass that rewrites legacy top-level keys. +# +# The framework is component-agnostic, so the integration tests inject a +# synthetic alias map (pointing a fake legacy name at the real `esp32` +# component) rather than depending on any specific renamed component. + +# A legacy name that is NOT a real component, used as a synthetic alias. +_FAKE_ALIAS = "esp32_legacy_alias" + + +def _write_component(root: Path, name: str, body: str) -> None: + """Write a fake component package at ``root//__init__.py``.""" + pkg = root / name + pkg.mkdir() + (pkg / "__init__.py").write_text(body) + + +def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None: + """AST scan should pick up ``ALIASES = ["legacy"]`` without executing.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['legacy_name']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == ["legacy_name"] + assert removal is None + + +def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: + """``ALIAS_REMOVAL_VERSION`` should be paired with the alias list.""" + init = tmp_path / "__init__.py" + init.write_text( + textwrap.dedent("""\ + ALIASES = ['old'] + ALIAS_REMOVAL_VERSION = "2027.6.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.6.0" + + +def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: + """A call-expression / non-literal ALIASES shouldn't surface — the + scanner deliberately ignores anything non-static to keep behavior + predictable (and avoid executing component code).""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n") + aliases, _ = _read_aliases(init, ast) + assert aliases == [] + + +def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text("CODEOWNERS = ['@me']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == [] + assert removal is None + + +def test_read_aliases_handles_syntax_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner logs a warning and + yields nothing so other components keep working. The substring pre-filter + only skips files with no ``ALIASES`` token, so this file (which has one) + still reaches the parse.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['x']\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + assert "Could not parse" in caplog.text + + +def test_read_aliases_handles_read_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable __init__.py logs a warning and yields nothing rather + than aborting the whole component scan.""" + missing = tmp_path / "nope" / "__init__.py" + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: + """End-to-end map build over a fake components dir.""" + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "other", "") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, meta_map = _build_alias_map() + + assert alias_map == {"oldcomp": "newcomp"} + assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)} + + +def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None: + _write_component( + tmp_path, + "newcomp", + "ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n", + ) + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + _, meta_map = _build_alias_map() + + assert meta_map["oldcomp"].removal_version == "2028.1.0" + + +def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None: + """If two canonical components both claim the same legacy alias, + routing becomes ambiguous — the build must refuse to start so the + conflict surfaces immediately at import time, not later as a + 'mysterious wrong component' bug.""" + _write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n") + _write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shared"), + ): + _build_alias_map() + + +def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: + """If the components directory doesn't exist (unlikely in production, + but possible in some test contexts), we want an empty map rather than + a crash — the rest of the loader can still function.""" + fake = tmp_path / "does-not-exist" + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias that names an existing component package is refused: it would + hijack a live domain, and a self-alias (alias == canonical) would send + ``_lookup_module`` into infinite recursion.""" + # `newcomp` declares itself as an alias — its own package already exists. + _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- + + +def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: + """Force the loader's alias map (used by the finder and get_component). + + Patches the lazily-built caches so both ``_get_alias_map`` and the + installed meta-path finder resolve against ``mapping`` regardless of + what the real on-disk scan would produce. + """ + monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) + + +def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """``get_component()`` should return the canonical manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + import esphome.loader as loader_mod + + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) + + canonical = get_component("esp32") + aliased = get_component(_FAKE_ALIAS) + assert canonical is not None + assert aliased is canonical + + +def test_alias_finder_resolves_top_level_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``import esphome.components.`` resolves to the canonical + module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + assert spec is not None + + import esphome.components.esp32 + import esphome.components.esp32_legacy_alias + + assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + + +def test_alias_finder_resolves_submodule_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from esphome.components. import boards`` routes through to + ``esphome.components.esp32.boards`` — same submodule object on both paths. + + The canonical submodule is imported first so its parent module carries + the ``boards`` attribute; ``from import boards`` then resolves + the aliased parent (via the finder) and reads that same attribute, + rather than triggering a fresh file load under the alias name. + ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + assert spec is not None + + from esphome.components.esp32 import boards as canonical_boards + from esphome.components.esp32_legacy_alias import boards as aliased_boards + + assert aliased_boards is canonical_boards + + +def test_alias_finder_ignores_non_components_path() -> None: + """The finder must scope itself to ``esphome.components.`` — + everything else (other esphome submodules, third-party packages) is + left for the normal import machinery.""" + finder = _AliasFinder() + assert finder.find_spec("esphome.core", None) is None + assert finder.find_spec("os.path", None) is None + # `esphome.components` itself (no domain segment) is not a candidate. + assert finder.find_spec("esphome.components", None) is None + # A real, non-aliased component domain defers to normal import machinery + # (no component declares an alias in this repo, so the live map is empty). + assert finder.find_spec("esphome.components.logger", None) is None + + +# --------------------------------------------------------------------------- +# YAML pre-pass: top-level key rename + centralized deprecation warning +# --------------------------------------------------------------------------- +# +# The companion to the loader-side alias map: ``esphome.config`` runs a +# pre-pass over the user's parsed YAML that rewrites legacy top-level keys +# to their canonical names, surfacing a one-shot deprecation warning. These +# tests inject a synthetic alias-metadata map so the rewrite behavior, the +# warning text, and the both-keys-present conflict can be tested in isolation. + + +def _patch_alias_metadata( + monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] +) -> None: + monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) + + +def test_resolve_component_aliases_renames_legacy_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A legacy alias key should be renamed to the canonical key and a + deprecation warning citing the removal version logged.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "oldcomp" not in config + assert config["newcomp"] == {"board": "x"} + assert any( + "'oldcomp:' top-level key is deprecated" in record.message + and "rename it to 'newcomp:'" in record.message + and "2027.6.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validators can run twice (auto-load discovery + final pass) + so the rename pass must emit the warning only once per alias per run. + Deduped via ``CORE.data``; cleared between runs.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"oldcomp": {"board": "a"}}) + _resolve_component_aliases({"oldcomp": {"board": "b"}}) + + matches = [ + r + for r in caplog.records + if "'oldcomp:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_canonical_key_after_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-keys conflict must be detected even when the canonical key + appears *after* the legacy key in the config (the up-front conflict + scan, not a position-dependent check).""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two different deprecated aliases of the same canonical component is + ambiguous — silently keeping one would hide a misconfiguration.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + { + "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), + "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), + }, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} + with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_preserves_key_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The renamed canonical key keeps the legacy key's original position + rather than being moved to the end of the config.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} + + _resolve_component_aliases(config) + + assert list(config) == ["esphome", "newcomp", "logger"] + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + original = dict(config) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# ComponentManifest alias properties +# --------------------------------------------------------------------------- + + +def test_component_manifest_alias_properties_default_empty() -> None: + """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` + when the component module declares neither. + + Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the + ``getattr(..., default)`` fallback is actually exercised — a bare mock + auto-creates any attribute on access and would never hit the default.""" + mod = ModuleType("fake_component") + manifest = ComponentManifest(mod) + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + + +def test_component_manifest_alias_properties_read_module_values() -> None: + """The properties surface the module's declared values verbatim.""" + mod = MagicMock() + mod.ALIASES = ["legacy"] + mod.ALIAS_REMOVAL_VERSION = "2027.6.0" + manifest = ComponentManifest(mod) + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2027.6.0" + + +# --------------------------------------------------------------------------- +# Real (unpatched) lazy build + cache and remaining scanner branches +# --------------------------------------------------------------------------- + + +def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the real lazy build over the actual components dir (no patch): + the first call scans and caches, the second returns the cached object.""" + monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) + first = loader_mod._get_alias_map() + second = loader_mod._get_alias_map() + assert isinstance(first, dict) + assert first is second # cached, not rebuilt on the second call + + +def test_get_alias_metadata_real_build_and_caches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) + first = loader_mod.get_alias_metadata() + second = loader_mod.get_alias_metadata() + assert isinstance(first, dict) + assert first is second + + +def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: + """Loose files and directories without an ``__init__.py`` are ignored; + only real component packages contribute to the map.""" + (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") + (tmp_path / "initless").mkdir() # a dir, but no __init__.py + _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + + assert alias_map == {"legacy": "realcomp"} + + +def test_read_aliases_ignores_non_assignment_and_complex_targets( + tmp_path: Path, +) -> None: + """Non-assignment statements and assignments to non-Name targets are + skipped; only simple ``NAME = ...`` assignments are read.""" + init = tmp_path / "__init__.py" + init.write_text( + "import os\n" # non-Assign (Import) node -> skipped + "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped + "ALIASES = ['legacy']\n" + ) + aliases, _ = _read_aliases(init, ast) + assert aliases == ["legacy"] + + +# --------------------------------------------------------------------------- +# Finder / loader edge branches +# --------------------------------------------------------------------------- + + +def test_alias_finder_returns_none_when_canonical_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias points at a canonical *target* that doesn't exist, the + finder declines (returns None) and lets normal import machinery report + the missing module.""" + _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) + finder = _AliasFinder() + assert finder.find_spec("esphome.components.broken_alias", None) is None + + +def test_alias_finder_reraises_when_canonical_dependency_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the canonical module exists but fails to import one of its own + dependencies, the finder surfaces that real error instead of masking it + as an unresolved alias (which would silently fall through to a confusing + 'no module named ').""" + _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) + + def boom(name: str) -> None: + raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") + + monkeypatch.setattr("esphome.loader.importlib.import_module", boom) + finder = _AliasFinder() + with pytest.raises(ModuleNotFoundError, match="missing_dep"): + finder.find_spec("esphome.components.some_alias", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """The finder is installed once at import; calling the installer again is + a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" + before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(before) == 1 # installed at module import time + loader_mod._install_alias_finder() + after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(after) == 1 + + +def test_get_component_alias_to_missing_canonical_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias resolves to a canonical component that can't be loaded, + ``get_component`` returns None and caches no bogus manifest.""" + _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) + loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) + + assert get_component("ghost_alias") is None + assert "ghost_alias" not in loader_mod._COMPONENT_CACHE + + +# --------------------------------------------------------------------------- +# YAML pre-pass: empty-map fast path + validate_config integration +# --------------------------------------------------------------------------- + + +def test_resolve_component_aliases_noop_when_no_aliases_declared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no component declares an alias, the pre-pass returns immediately + without inspecting or mutating the config.""" + from esphome.config import _resolve_component_aliases + + monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map + config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} + original = dict(config) + _resolve_component_aliases(config) + assert config == original + + +def _default_component_mock() -> Mock: + """A permissive component mock that validates any config (ALLOW_EXTRA).""" + return Mock( + auto_load=[], + is_platform_component=False, + is_platform=False, + multi_conf=False, + multi_conf_no_default=False, + dependencies=[], + conflicts_with=[], + config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), + ) + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_renames_alias_key( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: a legacy top-level key is renamed to its canonical name + before the rest of ``validate_config`` runs, and validation succeeds. + + A real ``esp32`` target platform is included so ``preload_core_config`` + is satisfied and validation runs to completion (the renamed canonical + key is loaded via the mocked, permissive component).""" + mock_get_component.side_effect = lambda name: _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: { + "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") + }, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "esp32": {"board": "esp32dev"}, + "legacyfoo": {"opt": 1}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert not result.errors, f"unexpected errors: {result.errors}" + assert "newcomp" in result + assert "legacyfoo" not in result + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_reports_alias_conflict_as_error( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """If both the legacy and canonical keys are present, ``validate_config`` + surfaces the conflict as a config error (the ``vol.Invalid`` path).""" + mock_get_component.return_value = _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "newcomp": {"opt": 1}, + "legacyfoo": {"opt": 2}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert result.errors + assert "Both 'legacyfoo:'" in str(result.errors) From ac6a0f34ecbaec6217e5701bcfa8b825ed0aa6f8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:45:30 -0400 Subject: [PATCH 200/219] [esp32] Make ESP-IDF the default toolchain (#16910) --- esphome/components/esp32/__init__.py | 2 +- .../esp32/config/flash_mode_idf.yaml | 1 + tests/component_tests/esp32/test_esp32.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b476..3ffec6b8263 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -964,7 +964,7 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # 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 = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) return value diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml index 7c7f50a4399..d12d4a734b2 100644 --- a/tests/component_tests/esp32/config/flash_mode_idf.yaml +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -5,5 +5,6 @@ esp32: board: esp32dev flash_mode: qio flash_frequency: 80MHz + toolchain: platformio framework: type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a8b5720a80b..e3311f68602 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -64,6 +64,38 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + # No `toolchain:` set -> the new default for esp32. + (None, Toolchain.ESP_IDF), + # An explicit `toolchain:` still wins over the default. + (Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO), + (Toolchain.ESP_IDF.value, Toolchain.ESP_IDF), + ], +) +def test_esp32_default_toolchain_is_esp_idf( + set_core_config: SetCoreConfigCallable, + config_toolchain: str | None, + expected: Toolchain, +) -> None: + """With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves + to the ESP-IDF toolchain; an explicit `toolchain:` still wins.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + # Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain. + CORE.toolchain = None + config: dict[str, Any] = {"variant": VARIANT_ESP32} + if config_toolchain is not None: + config["toolchain"] = config_toolchain + + CONFIG_SCHEMA(config) + + assert CORE.toolchain == expected + + @pytest.mark.parametrize( ("config", "error_match"), [ From 4b8568e94824341dd49aa8ee05d5f5927f65af9c Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 17 Jun 2026 21:54:29 -0400 Subject: [PATCH 201/219] [socket] bugfix Set wake-request gate flag on LwIP socket receive event (#17010) Co-authored-by: Claude Sonnet 4.6 --- esphome/core/lwip_fast_select.c | 7 +- esphome/core/wake/wake_freertos.cpp | 5 ++ esphome/core/wake/wake_host.cpp | 8 ++ .../fixtures/socket_wake_gate_tcp.yaml | 27 +++++++ .../integration/test_socket_wake_gate_tcp.py | 75 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_tcp.yaml create mode 100644 tests/integration/test_socket_wake_gate_tcp.py diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 36000d4e777..2042c438044 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +extern void esphome_wake_loop_threadsafe(void); + #ifdef USE_OTA_PLATFORM_ESPHOME static struct netconn *s_ota_listener_conn = NULL; extern void esphome_wake_ota_component_any_context(void); @@ -189,10 +191,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt esphome_wake_ota_component_any_context(); } #endif - TaskHandle_t task = esphome_main_task_handle; - if (task != NULL) { - xTaskNotifyGive(task); - } + esphome_wake_loop_threadsafe(); } } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 0bf700daa89..458ef51f89f 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,4 +30,9 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome +extern "C" void esphome_wake_loop_threadsafe() { + esphome::wake_request_set(); + esphome_main_task_notify(); +} + #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9d2a650ca24..8cb382a77e0 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,6 +123,14 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } + // A socket woke select() early — open the component-phase gate so the + // owning component's loop() drains the data on this tick rather than + // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() + // already set the flag (wake socket fired); required when an application + // socket fired and nothing else set the flag. + if (ret > 0) { + wake_request_set(); + } return; } // ret < 0: error (EINTR is normal, anything else is unexpected). diff --git a/tests/integration/fixtures/socket_wake_gate_tcp.yaml b/tests/integration/fixtures/socket_wake_gate_tcp.yaml new file mode 100644 index 00000000000..4dbf89cbf0d --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_tcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-tcp + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without wake_request_set() being + // called when select() returns due to socket data, the component + // phase would be gated for up to 2000ms after a TCP request arrives. + App.set_loop_interval(2000); + # Let boot transients and API handshake settle. + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + actions: + - action: ping + then: + - logger.log: + format: "PONG" + level: INFO + +logger: + level: INFO diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py new file mode 100644 index 00000000000..2955d2803a5 --- /dev/null +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -0,0 +1,75 @@ +"""Test that a TCP socket receive opens the component-phase gate immediately. + +Regression test for the wake-request flag not being set when select() returns +due to socket data on the host platform (wake_host.cpp wakeable_delay fix). + +The API server's accepted connection sockets use accept_loop_monitored(), so +they are registered with the host select() loop. A service call from the Python +client arrives on that socket. Without the fix, select() returning early did not +set g_wake_requested, so Application::loop()'s Phase B gate stayed closed until +loop_interval_ expired. With the fix, the gate opens immediately. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_tcp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """TCP socket receive must open the component-phase gate immediately, + even with loop_interval_ raised to 2000ms.""" + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "socket-wake-gate-tcp" + + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared — device did not complete boot") + + _, services = await client.list_entities_services() + ping_service = next((s for s in services if s.name == "ping"), None) + assert ping_service is not None, "ping service not found" + + # Execute the service and time how long until PONG appears in logs. + # The request bytes arrive on an accept_loop_monitored() TCP socket, + # which is registered with the host select() loop. + t_send = time.monotonic() + await client.execute_service(ping_service, {}) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared — service did not execute") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the fix the gate stays closed for up to loop_interval_=2000ms. + # With the fix the gate opens on the next tick; 500ms gives ample CI headroom. + assert elapsed_ms < 500, ( + f"Service response took {elapsed_ms:.0f}ms with loop_interval_=2000ms — " + f"expected < 500ms; without the wake-request fix this would take up to 2000ms" + ) From f76dfd579cbe64e619440e04d70f39cf858edc09 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:58:51 +0100 Subject: [PATCH 202/219] [openthread] Add basic Openthread support to Zephyr/nRF52 platform (#16854) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 72 +++++++-- esphome/components/openthread/openthread.cpp | 22 ++- esphome/components/openthread/openthread.h | 3 +- .../components/openthread/openthread_esp.cpp | 2 +- .../openthread/openthread_zephyr.cpp | 141 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 7 +- .../openthread/test.nrf52-adafruit.yaml | 5 + 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 esphome/components/openthread/openthread_zephyr.cpp create mode 100644 tests/components/openthread/test.nrf52-adafruit.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6dac..215f9212293 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -10,6 +10,8 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -20,6 +22,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +55,6 @@ AUTO_LOAD = ["network"] # Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", @@ -98,9 +100,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - if tlv := config.get(CONF_TLV): - cg.add_define("USE_OPENTHREAD_TLVS", tlv) - else: + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -128,9 +128,6 @@ def set_sdkconfig_options(config): "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() ) - if config.get(CONF_FORCE_DATASET): - cg.add_define("USE_OPENTHREAD_FORCE_DATASET") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) @@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema( def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: config[CONF_USE_ADDRESS] = f"{CORE.name}.local" + if CORE.using_zephyr and CONF_TLV not in config: + raise cv.Invalid( + "On nRF52, OpenThread credentials must be provided via 'tlv'. " + "Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform." + ) device_type = config.get(CONF_DEVICE_TYPE) poll_period = config.get(CONF_POLL_PERIOD) if ( @@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType: def _require_vfs_select(config): """Register VFS select requirement during config validation.""" - # OpenThread uses esp_vfs_eventfd which requires VFS select support - require_vfs_select() + # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) + if CORE.is_esp32: + require_vfs_select() return config +def _validate_platform(config): + if CORE.using_zephyr: + return config + return only_on_variant( + supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + )(config) + + +def _validate_tlv_hex(value): + s = cv.string_strict(value) + if len(s) % 2 != 0: + raise cv.Invalid("TLV must have an even number of hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as e: + raise cv.Invalid(f"TLV must be valid hex: {e}") from e + if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) + raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") + return s + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.string_strict, + cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( @@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), - only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), + _validate_platform, _validate, _require_vfs_select, ) @@ -227,13 +251,27 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "openthread_esp.cpp": { + PlatformFramework.ESP32_IDF, + }, + "openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) - include_builtin_idf_component("openthread") + if CORE.is_esp32: + include_builtin_idf_component("openthread") cg.add_define("USE_OPENTHREAD") + if config.get(CONF_FORCE_DATASET): + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) # OpenThread SRP needs access to mDNS services after setup enable_mdns_storage() @@ -252,4 +290,12 @@ async def to_code(config): if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) - set_sdkconfig_options(config) + if CORE.is_esp32: + set_sdkconfig_options(config) + elif CORE.using_zephyr: + zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) + zephyr_add_prj_conf( + f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True + ) + zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c8ffc02131a..102424c62e0 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); return; } @@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Get mdns services and copy their data (strings are copied with strdup below) + // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr) const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { @@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() { // Set service name char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); - if (full_service.size() > size) { + if (full_service.size() >= size) { ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); continue; } @@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() { // Set instance name (using host_name) string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); continue; } @@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() { for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &txt = service.txt_records[i]; // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ - // OpenThread SRP client expects the data to persist, so we strdup it + // OpenThread SRP client expects the data to persist, so we copy it const char *value_str = MDNS_STR_ARG(txt.value); txt_entries[i].mKey = MDNS_STR_ARG(txt.key); +#ifndef USE_ZEPHYR txt_entries[i].mValue = reinterpret_cast(strdup(value_str)); txt_entries[i].mValueLength = strlen(value_str); +#else + // strdup is not available on zephyr + // https:// github.com/zephyrproject-rtos/zephyr/issues/22464 + size_t value_len = strlen(value_str); + char *value_copy = reinterpret_cast(this->pool_alloc_(value_len + 1)); + memcpy(value_copy, value_str, value_len + 1); + txt_entries[i].mValue = reinterpret_cast(value_copy); + txt_entries[i].mValueLength = value_len; +#endif } entry->mService.mTxtEntries = txt_entries; entry->mService.mNumTxtEntries = service.txt_records.size(); @@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() { global_openthread_component = nullptr; ESP_LOGD(TAG, "Exit main loop "); int error = this->openthread_stop_(); - if (error != ESP_OK) { + if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); this->teardown_complete_ = true; } diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 96f1abdb924..f1c79fb9cbd 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -43,10 +43,11 @@ class OpenThreadComponent : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } + void set_connected(bool connected) { this->connected_ = connected; } + static void on_state_changed(otChangedFlags flags, void *context); protected: std::optional get_omr_address_(InstanceLock &lock); - 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 4d88cbd2264..6edaa98524c 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } InstanceLock InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } return InstanceLock(esp_openthread_lock_acquire(delay)); diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp new file mode 100644 index 00000000000..7b9f14ab8ce --- /dev/null +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -0,0 +1,141 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_NRF52) +#include +#include +#include +#include "openthread.h" +#include "esphome/core/helpers.h" +#include + +static const char *const TAG = "openthread"; + +namespace esphome::openthread { + +static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) { + // Delegate connection status tracking to common callback + if (global_openthread_component != nullptr) { + OpenThreadComponent::on_state_changed(flags, global_openthread_component); + } + if (flags & OT_CHANGED_THREAD_ROLE) { + otDeviceRole role = otThreadGetDeviceRole(ot_context->instance); + ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role)); + } + if (flags & OT_CHANGED_THREAD_NETDATA) { + ESP_LOGI(TAG, "Thread network data updated"); + } + if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) { + char buf[NET_IPV6_ADDR_LEN]; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr; + addr = addr->mNext) { + ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf))); + } + } +} + +static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed}; + +void OpenThreadComponent::setup() { + struct openthread_context *context = openthread_get_default_context(); + this->lock_initialized_ = true; + otOperationalDatasetTlvs dataset = {}; + +#ifndef USE_OPENTHREAD_FORCE_DATASET + otError error = otDatasetGetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } +#endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1; + if ((tlv_chars % 2) != 0) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars); + this->mark_failed(); + return; + } + + size_t len = tlv_chars / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len); + this->mark_failed(); + return; + } + + size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len); + if (parsed != tlv_chars) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed); + this->mark_failed(); + return; + } + dataset.mLength = len; + } +#endif + if (dataset.mLength > 0) { + otError error = otDatasetSetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error)); + this->mark_failed(); + return; + } + } + openthread_state_changed_cb_register(context, &ot_state_changed_cb); + openthread_start(context); +} + +void OpenThreadComponent::ot_main() {} + +otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } + +int OpenThreadComponent::openthread_stop_() { + // OT stack is intentionally left running — no Zephyr stop API. The state callback stays + // registered but is safe (null-checks global_openthread_component). nRF52840 never + // re-enters setup() after teardown so this is functionally correct. + this->teardown_complete_ = true; + return 0; +} + +network::IPAddresses OpenThreadComponent::get_ip_addresses() { + network::IPAddresses addresses; + auto lock = InstanceLock::acquire(); + size_t addr_count = 0; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance()); + addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) { + struct in6_addr ip6; + memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6)); + addresses[addr_count + 1] = network::IPAddress(&ip6); + addr_count++; + } + return addresses; +} + +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); + } + struct openthread_context *ot_context = openthread_get_default_context(); + if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) { + return InstanceLock(true); + } + return InstanceLock(false); +} + +InstanceLock InstanceLock::acquire() { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_lock(&ot_context->api_lock, K_FOREVER); + return InstanceLock(true); +} + +otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); } + +InstanceLock::~InstanceLock() { + if (this->owns_) { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_unlock(&ot_context->api_lock); + } +} + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d547..bd5f01aa3aa 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True, image: str = "" + name: str, + value: PrjConfValueType, + required: bool = True, + image: str = "", ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): @@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None: # os: ***** USAGE FAULT ***** # os: Illegal load of EXC_RETURN into PC - zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) CORE.add_job(_cdc_acm_to_code, config) diff --git a/tests/components/openthread/test.nrf52-adafruit.yaml b/tests/components/openthread/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..ac2fe63739c --- /dev/null +++ b/tests/components/openthread/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 From b6763cfaed5dfd1a2d40b7e0d3f8866ac184a1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:07 +1200 Subject: [PATCH 203/219] [ci] Smoke-test docker image by compiling each target toolchain (#16995) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci-docker.yml | 102 ++++++++++++++++-- docker/test_configs/bk72xx-arduino.yaml | 7 ++ .../test_configs/esp32-arduino-esp-idf.yaml | 10 ++ .../esp32-arduino-platformio.yaml | 10 ++ docker/test_configs/esp32-idf-esp-idf.yaml | 10 ++ docker/test_configs/esp32-idf-platformio.yaml | 10 ++ docker/test_configs/esp8266-arduino.yaml | 7 ++ docker/test_configs/host.yaml | 6 ++ docker/test_configs/ln882x-arduino.yaml | 7 ++ docker/test_configs/nrf52.yaml | 8 ++ docker/test_configs/rp2040-arduino.yaml | 7 ++ docker/test_configs/rtl87xx-arduino.yaml | 7 ++ 12 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 docker/test_configs/bk72xx-arduino.yaml create mode 100644 docker/test_configs/esp32-arduino-esp-idf.yaml create mode 100644 docker/test_configs/esp32-arduino-platformio.yaml create mode 100644 docker/test_configs/esp32-idf-esp-idf.yaml create mode 100644 docker/test_configs/esp32-idf-platformio.yaml create mode 100644 docker/test_configs/esp8266-arduino.yaml create mode 100644 docker/test_configs/host.yaml create mode 100644 docker/test_configs/ln882x-arduino.yaml create mode 100644 docker/test_configs/nrf52.yaml create mode 100644 docker/test_configs/rp2040-arduino.yaml create mode 100644 docker/test_configs/rtl87xx-arduino.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7d4b8503567..373cd905b19 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,25 +1,38 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: contents: read # actions/checkout only @@ -96,7 +109,26 @@ jobs: --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ --registry ghcr \ - build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + + - name: Upload compile-test image artifact + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The tar is already gzipped, so upload it as-is. archive: false skips + # the redundant zip and makes the file name the artifact name (the + # `name` input is ignored in that mode). + path: compile-test-image.tar.gz + retention-days: 1 + archive: false manifest: name: Push ${{ matrix.build_type }} manifest to ghcr.io @@ -135,3 +167,51 @@ jobs: --build-type "${{ matrix.build_type }}" \ --registry ghcr \ manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + # Cap concurrency so this smoke test doesn't hog all the shared runners. + max-parallel: 2 + matrix: + # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) + # share a toolchain bundle, so esp32 is exercised on the base variant + # across the full framework x toolchain cross-product (arduino/esp-idf + # framework, each built with the platformio and native esp-idf + # toolchains) so both toolchains stay covered regardless of which one is + # the default. + id: + - esp8266-arduino + - esp32-arduino-platformio + - esp32-arduino-esp-idf + - esp32-idf-platformio + - esp32-idf-esp-idf + - rp2040-arduino + - bk72xx-arduino + - rtl87xx-arduino + - ln882x-arduino + - nrf52 + - host + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.gz + - name: Load image + run: docker load --input compile-test-image.tar.gz + - name: Compile ${{ matrix.id }} + run: | + docker run --rm \ + -v "${{ github.workspace }}/docker/test_configs:/config" \ + "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ + compile "${{ matrix.id }}.yaml" diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml new file mode 100644 index 00000000000..138aa9e282c --- /dev/null +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-bk72xx-arduino + +bk72xx: + board: generic-bk7231n-qfn32-tuya + +logger: diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml new file mode 100644 index 00000000000..fbc68aff0c3 --- /dev/null +++ b/docker/test_configs/esp32-arduino-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-idf + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml new file mode 100644 index 00000000000..e216c020599 --- /dev/null +++ b/docker/test_configs/esp32-arduino-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-pio + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml new file mode 100644 index 00000000000..b180aa9c0a4 --- /dev/null +++ b/docker/test_configs/esp32-idf-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-idf + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml new file mode 100644 index 00000000000..5aec23e40d2 --- /dev/null +++ b/docker/test_configs/esp32-idf-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-pio + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml new file mode 100644 index 00000000000..80b52260e4d --- /dev/null +++ b/docker/test_configs/esp8266-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-esp8266-arduino + +esp8266: + board: d1_mini + +logger: diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml new file mode 100644 index 00000000000..9f990693049 --- /dev/null +++ b/docker/test_configs/host.yaml @@ -0,0 +1,6 @@ +esphome: + name: docker-test-host + +host: + +logger: diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml new file mode 100644 index 00000000000..4cff3a48837 --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882hki + +logger: diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml new file mode 100644 index 00000000000..d6337149cc8 --- /dev/null +++ b/docker/test_configs/nrf52.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-nrf52 + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml new file mode 100644 index 00000000000..4b5df11d875 --- /dev/null +++ b/docker/test_configs/rp2040-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rp2040-arduino + +rp2040: + variant: rp2040 + +logger: diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml new file mode 100644 index 00000000000..e8d9cf75035 --- /dev/null +++ b/docker/test_configs/rtl87xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rtl87xx-arduino + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: From 3a1a8a89559477cbab10c5b7bd73dacdd8edefef Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:22 +1200 Subject: [PATCH 204/219] [ci] Fail CI Status job when workflow is cancelled (#17024) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b1032bcde7..aca6d9007a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,4 +1369,7 @@ jobs: # 1. The target branch has a build issue independent of this PR # 2. This PR fixes a build issue on the target branch # In either case, we only care that the PR branch builds successfully. - echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' + # Every other job must have succeeded or been skipped; a "cancelled" or + # "failure" result fails this check so CI is not reported green when the + # workflow was cancelled. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")' From c2784c9fd8a388a4edbc2ec208101fc72be9686a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Jun 2026 22:09:39 -0500 Subject: [PATCH 205/219] [esp32] Consolidate network/coexistence sdkconfig into a single reconciler (#17008) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/esp32/__init__.py | 126 ++++++++++- esphome/components/esp32/const.py | 1 + esphome/components/esp32_ble/__init__.py | 10 +- .../components/esp32_ble_beacon/__init__.py | 5 +- .../components/esp32_ble_server/__init__.py | 4 +- .../components/esp32_ble_tracker/__init__.py | 10 +- esphome/components/ethernet/__init__.py | 8 +- esphome/components/wifi/__init__.py | 9 +- .../esp32/config/network_ethernet_only.yaml | 17 ++ .../config/network_wifi_ble_coexistence.yaml | 14 ++ .../esp32/config/network_wifi_only.yaml | 11 + tests/component_tests/esp32/test_esp32.py | 195 +++++++++++++++++- 12 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/esp32/config/network_ethernet_only.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_only.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3ffec6b8263..aee86a0554e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -69,6 +69,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -597,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value +@dataclass +class NetworkSdkconfigData: + """Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL. + + Components call the request_*() helpers below (and esp32's own to_code fills + in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth + sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine + then decides the final values so they no longer depend on call order. + """ + + wifi: bool = False # WiFi component active (STA and/or AP) + wifi_ap: bool = False # WiFi AP mode configured + ethernet: bool = False # Ethernet component active + bluetooth: bool = False # any BLE component active + ble_42: bool = False # BLE 4.2 features needed + software_coexistence: bool = False # WiFi/BT software coexistence requested + # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) + enable_lwip_dhcp_server: bool | None = None + + +def _network_sdkconfig() -> NetworkSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_NETWORK_SDKCONFIG not in data: + data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData() + return data[KEY_NETWORK_SDKCONFIG] + + +def request_wifi(ap: bool = False) -> None: + """Request the WiFi stack. Pass ap=True when AP mode is configured.""" + net = _network_sdkconfig() + net.wifi = True + if ap: + net.wifi_ap = True + + +def request_ethernet() -> None: + """Request the Ethernet stack.""" + _network_sdkconfig().ethernet = True + + +def request_bluetooth(ble_42: bool = False) -> None: + """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" + net = _network_sdkconfig() + net.bluetooth = True + if ble_42: + net.ble_42 = True + + +def request_software_coexistence() -> None: + """Request WiFi/BT software coexistence (only valid alongside WiFi).""" + _network_sdkconfig().software_coexistence = True + + def add_idf_component( *, name: str, @@ -1847,6 +1901,61 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_network_sdkconfig() -> None: + """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. + + Single decision point for flags that multiple components used to set + directly (and sometimes with conflicting values). Runs at FINAL priority so + every request_*() call (made from the various components' to_code at their + own priorities) is seen first. A user-supplied sdkconfig_options value + always takes precedence. + """ + net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + is_arduino = CORE.using_arduino + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # Bluetooth: only ever enable when requested. The IDF default is off and + # nothing sets these False today, so never write False here. + if net.bluetooth: + set_opt("CONFIG_BT_ENABLED", True) + if net.ble_42: + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + + # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi + # relies on the IDF default (enabled), so it is never written True here. + wifi_disabled = net.ethernet and not net.wifi + if wifi_disabled: + set_opt("CONFIG_ESP_WIFI_ENABLED", False) + + # Software coexistence: enable when requested (the schema only allows it + # alongside WiFi). Disable only in the Ethernet-without-WiFi case. + if net.software_coexistence: + set_opt("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_opt("CONFIG_SW_COEXIST_ENABLE", False) + + # SoftAP support: drop it when WiFi is used without AP mode (IDF only). + if not is_arduino and net.wifi and not net.wifi_ap: + set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + + # LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not + # coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server + # option is set to false, unless Arduino+Ethernet needs the symbols to compile. + wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap + dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False + arduino_eth_exclusion = is_arduino and net.ethernet + if ( + wifi_wants_dhcps_off or dhcp_server_disabled_by_option + ) and not arduino_eth_exclusion: + set_opt("CONFIG_LWIP_DHCPS", 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.""" @@ -2171,14 +2280,12 @@ async def to_code(config): for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): include_builtin_idf_component(component_name) - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - # When using Arduino with Ethernet, DHCP server functions must be available - # for the Network library to compile, even if not actively used - if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig + # together with the WiFi component's own AP-mode optimization; record the user's + # advanced tristate (True/False/None) for it to consume at FINAL priority. + _network_sdkconfig().enable_lwip_dhcp_server = advanced.get( + CONF_ENABLE_LWIP_DHCP_SERVER + ) 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]: @@ -2397,6 +2504,9 @@ async def to_code(config): # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) + # FINAL priority: runs after every network/coexistence request_*() call + CORE.add_job(_reconcile_network_sdkconfig) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 322054ea912..83fcfd233e7 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c7b6b40394c..c9fb42fde4a 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -8,7 +8,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM -from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + const, + get_esp32_variant, + request_bluetooth, +) from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv from esphome.const import ( @@ -599,8 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596b..7a59cce19b4 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID @@ -86,5 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d45f2d9df25..ea2a9667d72 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED @@ -632,7 +632,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d758b400c4f..e4139bed651 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,7 +6,11 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble, ota -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -315,9 +319,9 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + request_software_coexistence() # https://github.com/espressif/esp-idf/issues/4101 # https://github.com/espressif/esp-idf/issues/2503 # Match arduino CONFIG_BTU_TASK_STACK_SIZE diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8cc..f6afc30ff23 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: @@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Register Ethernet with the esp32 sdkconfig reconciler, which disables the + # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d13..080a7bb97ba 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( has_high_performance_networking, @@ -594,9 +595,11 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32 and not CORE.using_arduino: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + + # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which + # drops SoftAP support / the LWIP DHCP server when AP mode is unused. + if CORE.is_esp32: + request_wifi(ap=CONF_AP in config) # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml new file mode 100644 index 00000000000..73d11e0a13d --- /dev/null +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz diff --git a/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml new file mode 100644 index 00000000000..9aff46b7c40 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +esp32_ble_tracker: + software_coexistence: true diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e3311f68602..bdba981c44d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,25 @@ Test ESP32 configuration """ +import asyncio from collections.abc import Callable from pathlib import Path from typing import Any import pytest -from esphome.components.esp32 import VARIANT_ESP32, VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANTS, + NetworkSdkconfigData, + _reconcile_network_sdkconfig, +) +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_NETWORK_SDKCONFIG, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv from esphome.const import ( @@ -343,3 +354,183 @@ def test_flash_mode_unset_leaves_defaults( assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) assert "board_build.flash_mode" not in CORE.platformio_options assert "board_build.f_flash" not in CORE.platformio_options + + +@pytest.mark.parametrize( + ("framework", "net", "preset", "expected"), + [ + # --- IDF: single-interface cases (must match pre-refactor behavior) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_no_ap", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, wifi_ap=True), + {}, + {}, + id="idf_wifi_ap_leaves_softap_dhcps", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="idf_ethernet_only", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, bluetooth=True, ble_42=True, software_coexistence=True + ), + {}, + { + "CONFIG_BT_ENABLED": True, + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_ble_tracker_coexistence", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(bluetooth=True), + {}, + {"CONFIG_BT_ENABLED": True}, + id="idf_ble_server_only_no_ble42", + ), + # --- IDF: user sdkconfig_options always win --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_user_override_wins", + ), + # --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False + ), + {}, + {"CONFIG_LWIP_DHCPS": False}, + id="idf_user_disables_dhcps_with_ap", + ), + # --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_and_ethernet_keeps_wifi_enabled", + ), + # --- Arduino: SoftAP/DHCPS disable is IDF-only --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(wifi=True), + {}, + {}, + id="arduino_wifi_no_ap_untouched", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_only_disables_wifi", + ), + # --- Arduino + Ethernet: DHCPS stays available even if user disabled it --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_dhcps_exclusion", + ), + ], +) +def test_reconcile_network_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + net: NetworkSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence + sdkconfig flags from the requests recorded in NetworkSdkconfigData.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_NETWORK_SDKCONFIG: net, + } + + asyncio.run(_reconcile_network_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_network_wifi_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler + after wifi's request_wifi(), disabling SoftAP support and the DHCP server.""" + generate_main(component_config_path("network_wifi_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi stack stays enabled (no ethernet) and no Bluetooth requested. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_BT_ENABLED" not in sdkconfig + + +def test_network_ethernet_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: ethernet's request_ethernet() makes the reconciler disable the + WiFi stack and coexistence when WiFi is absent.""" + generate_main(component_config_path("network_ethernet_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + + +def test_network_wifi_ble_coexistence_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to + BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP).""" + generate_main(component_config_path("network_wifi_ble_coexistence.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_BT_ENABLED") is True + assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi present alongside BT -> WiFi stack must stay enabled. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig From bd9375117a91d86854a81f0ba7090b2678309a92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 206/219] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834b..27dd878495d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc276..e44f746a750 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From d4b642608793a06249656ea16f26d5d97bcb58e6 Mon Sep 17 00:00:00 2001 From: "Thomas A." Date: Thu, 18 Jun 2026 05:12:22 +0200 Subject: [PATCH 207/219] [esp32] Pin Names for Seeed XIAO C3 / C6 / S3 (#17002) Co-authored-by: Thomas A <1294885+zeroflow@users.noreply.github.com> Co-authored-by: Claude --- esphome/components/esp32/boards.py | 90 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 6062631d984..729b0c89ab6 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1240,6 +1240,43 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + # Source: https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/ + # The XIAO ESP32-C3 has no user-controllable LED (only a hardwired charge + # LED), so LED/LED_BUILTIN are intentionally omitted. The Ax keys override + # the incorrect ESP32_BASE_PINS A* fallback (which otherwise makes pin: A0 + # resolve to phantom GPIO36 and pin: A1/A2 raise cv.Invalid). + "seeed_xiao_esp32c3": { + "D0": 2, + "D1": 3, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 7, + "D6": 21, + "D7": 20, + "D8": 8, + "D9": 9, + "D10": 10, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "TX": 21, + "RX": 20, + "SDA": 6, + "SCL": 7, + "SCK": 8, + "MISO": 9, + "MOSI": 10, + "A0": 2, + "A1": 3, + "A2": 4, + "A3": 5, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ + # The Ax keys override the incorrect ESP32_BASE_PINS A* fallback (which + # otherwise makes pin: A0 resolve to phantom GPIO36). "seeed_xiao_esp32c6": { "D0": 0, "D1": 1, @@ -1257,10 +1294,59 @@ ESP32_BOARD_PINS = { "MTDI": 5, "MTMS": 4, "BOOT": 9, - "LED": 8, - "LED_BUILTIN": 8, + "LED": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 + "LED_BUILTIN": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 "RF_SWITCH_EN": 3, "RF_ANT_SELECT": 14, + "TX": 16, + "RX": 17, + "SDA": 22, + "SCL": 23, + "SCK": 19, + "MISO": 20, + "MOSI": 18, + "A0": 0, + "A1": 1, + "A2": 2, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/ + # LED (GPIO21) is active-LOW; BOOT=GPIO0 is the standard ESP32-S3 strapping + # pin. The Ax keys override the incorrect ESP32_BASE_PINS A* fallback for the + # published silkscreen set. A6/A7 are intentionally absent (D6/D7 = GPIO43/44 + # have no ADC); because ESP32_BASE_PINS already defines A6=34/A7=35, pin: A6/A7 + # still resolve to those classic-ESP32 phantom values via the base-pins + # fallback (a disclosed residual, not fixable without editing ESP32_BASE_PINS). + "seeed_xiao_esp32s3": { + "D0": 1, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 5, + "D5": 6, + "D6": 43, + "D7": 44, + "D8": 7, + "D9": 8, + "D10": 9, + "BOOT": 0, + "LED": 21, + "LED_BUILTIN": 21, + "TX": 43, + "RX": 44, + "SDA": 5, + "SCL": 6, + "SCK": 7, + "MISO": 8, + "MOSI": 9, + "A0": 1, + "A1": 2, + "A2": 3, + "A3": 4, + "A4": 5, + "A5": 6, + "A8": 7, + "A9": 8, + "A10": 9, }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, From 9ace0ffb262a3cbbc24822a3ff036d1116fd39ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:15 -0400 Subject: [PATCH 208/219] Bump pylint from 4.0.5 to 4.0.6 (#16983) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5ba806a2f57..438d6cd0058 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.5 +pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From 26c42af35478ff74d7a02ef7ae9645508b0e71d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:46 -0400 Subject: [PATCH 209/219] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1 (#16986) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 aca6d9007a8..6ff846e4b2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 3b2564bbf3b7a31fae5794185fb740aca6b5cd3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:56 -0400 Subject: [PATCH 210/219] Bump cryptography from 48.0.1 to 49.0.0 (#16985) 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 4ef3df60ffc..efb5ec8723a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.1 +cryptography==49.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From c63bed8c217ebb127c7e9ffdf776c08207db7d26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:16:04 -0400 Subject: [PATCH 211/219] Bump pytest from 9.0.3 to 9.1.0 (#16981) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 438d6cd0058..fc9681921a6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.3 +pytest==9.1.0 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 2b38e4b7e2f0cfbd49a782855ff94373100916f5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 17 Jun 2026 23:23:18 -0400 Subject: [PATCH 212/219] [audio] Bump microMP3 to v0.3.0 (#17009) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/audio/__init__.py | 6 +++--- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2aceff0c97e..091f496e333 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,11 +395,11 @@ 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.3") + add_idf_component(name="esphome/micro-mp3", ref="0.3.0") _emit_memory_pair( data.mp3.buffer_memory, - "CONFIG_MP3_DECODER_PREFER_PSRAM", - "CONFIG_MP3_DECODER_PREFER_INTERNAL", + "CONFIG_MICRO_MP3_PREFER_PSRAM", + "CONFIG_MICRO_MP3_PREFER_INTERNAL", ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5f3000e52d0..b3b670d77b4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.3 + version: 0.3.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 11deff2bed04b9c887c311889cd35abb60efec3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:03 +1200 Subject: [PATCH 213/219] Mark configurable classes as final (1/21: a01nyub-aqi) (#16952) --- esphome/components/a01nyub/a01nyub.h | 2 +- esphome/components/a02yyuw/a02yyuw.h | 2 +- esphome/components/a4988/a4988.h | 2 +- .../absolute_humidity/absolute_humidity.h | 2 +- esphome/components/ac_dimmer/ac_dimmer.h | 2 +- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc128s102/adc128s102.h | 6 +++--- .../adc128s102/sensor/adc128s102_sensor.h | 8 ++++---- .../addressable_light_display.h | 2 +- esphome/components/ade7880/ade7880.h | 2 +- esphome/components/ade7953_i2c/ade7953_i2c.h | 2 +- esphome/components/ads1115/ads1115.h | 2 +- .../ads1115/sensor/ads1115_sensor.h | 8 ++++---- esphome/components/ads1118/ads1118.h | 6 +++--- .../ads1118/sensor/ads1118_sensor.h | 8 ++++---- esphome/components/ags10/ags10.h | 6 +++--- esphome/components/aht10/aht10.h | 2 +- esphome/components/aic3204/aic3204.h | 2 +- esphome/components/aic3204/automation.h | 2 +- .../airthings_ble/airthings_listener.h | 2 +- .../airthings_wave_mini/airthings_wave_mini.h | 2 +- .../airthings_wave_plus/airthings_wave_plus.h | 2 +- .../alarm_control_panel/automation.h | 14 +++++++------- esphome/components/alpha3/alpha3.h | 2 +- esphome/components/am2315c/am2315c.h | 2 +- esphome/components/am2320/am2320.h | 2 +- esphome/components/am43/cover/am43_cover.h | 2 +- esphome/components/am43/sensor/am43_sensor.h | 2 +- .../analog_threshold_binary_sensor.h | 2 +- esphome/components/animation/animation.h | 8 ++++---- esphome/components/anova/anova.h | 2 +- esphome/components/apds9306/apds9306.h | 2 +- esphome/components/apds9960/apds9960.h | 2 +- esphome/components/api/api_server.h | 2 +- .../components/api/homeassistant_service.h | 2 +- esphome/components/api/user_services.h | 19 ++++++++++--------- esphome/components/aqi/aqi_sensor.h | 2 +- 37 files changed, 70 insertions(+), 69 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 5c0d20bd378..69636eb8e4c 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -8,7 +8,7 @@ namespace esphome::a01nyub { -class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 693bcfd03c6..2e71651301e 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -8,7 +8,7 @@ namespace esphome::a02yyuw { -class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 04040241c0f..f50b5926c1c 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -6,7 +6,7 @@ namespace esphome::a4988 { -class A4988 : public stepper::Stepper, public Component { +class A4988 final : public stepper::Stepper, public Component { public: void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index be28d3dc509..9989bb17fc8 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation { }; /// This class implements calculation of absolute humidity from temperature and relative humidity. -class AbsoluteHumidityComponent : public sensor::Sensor, public Component { +class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index 6bfcf0bdb5b..783a9d7e246 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -41,7 +41,7 @@ struct AcDimmerDataStore { #endif }; -class AcDimmer : public output::FloatOutput, public Component { +class AcDimmer final : public output::FloatOutput, public Component { public: void setup() override; diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca12..03de6f8b4b1 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -54,7 +54,7 @@ template class Aggregator { SamplingMode mode_{SamplingMode::AVG}; }; -class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: /// Update the sensor's state by reading the current ADC value. /// This method is called periodically based on the update interval. diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index f04ed87b2af..7d6355815e6 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -6,9 +6,9 @@ namespace esphome::adc128s102 { -class ADC128S102 : public Component, - public spi::SPIDevice { +class ADC128S102 final : public Component, + public spi::SPIDevice { public: ADC128S102() = default; diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index c840102380f..3c42e709f27 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -9,10 +9,10 @@ namespace esphome::adc128s102 { -class ADC128S102Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class ADC128S102Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: ADC128S102Sensor(uint8_t channel); diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 917d334f05f..39d62b87335 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -9,7 +9,7 @@ namespace esphome::addressable_light { -class AddressableLightDisplay : public display::DisplayBuffer { +class AddressableLightDisplay final : public display::DisplayBuffer { public: light::AddressableLight *get_light() const { return this->light_; } diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 53f501dee26..12be0849ffa 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -65,7 +65,7 @@ struct ADE7880Store { static void gpio_intr(ADE7880Store *arg); }; -class ADE7880 : public i2c::I2CDevice, public PollingComponent { +class ADE7880 final : public i2c::I2CDevice, public PollingComponent { public: void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; } void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; } diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 74d7e3e7cce..0b368a73ee9 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -10,7 +10,7 @@ namespace esphome::ade7953_i2c { -class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { +class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h index b1eed68aff2..0b7f7ae7005 100644 --- a/esphome/components/ads1115/ads1115.h +++ b/esphome/components/ads1115/ads1115.h @@ -43,7 +43,7 @@ enum ADS1115Samplerate { ADS1115_860SPS = 0b111 }; -class ADS1115Component : public Component, public i2c::I2CDevice { +class ADS1115Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 3b82c153dd5..ecc8fb7af8f 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -11,10 +11,10 @@ namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. -class ADS1115Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1115Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; } diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index ef125a0b44b..275933c70d7 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -26,9 +26,9 @@ enum ADS1118Gain { ADS1118_GAIN_0P256 = 0b101, }; -class ADS1118 : public Component, - public spi::SPIDevice { +class ADS1118 final : public Component, + public spi::SPIDevice { public: ADS1118() = default; void setup() override; diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index b929e75c62d..8987dba0732 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -10,10 +10,10 @@ namespace esphome::ads1118 { -class ADS1118Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1118Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 703acd5228c..8ebc8da544a 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -7,7 +7,7 @@ namespace esphome::ags10 { -class AGS10Component : public PollingComponent, public i2c::I2CDevice { +class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** * Sets TVOC sensor. @@ -100,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction : public Action, public Parented { +template class AGS10NewI2cAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, new_address) @@ -116,7 +116,7 @@ enum AGS10SetZeroPointActionMode { CUSTOM_VALUE, }; -template class AGS10SetZeroPointAction : public Action, public Parented { +template class AGS10SetZeroPointAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, value) TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index 7b9b1761c4d..e99ba6fb98a 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -10,7 +10,7 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; -class AHT10Component : public PollingComponent, public i2c::I2CDevice { +class AHT10Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 9b8c7928246..ae99a8f4d6a 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -61,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config -class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 50ae03edbd9..f0f88566145 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -template class SetAutoMuteAction : public Action { +template class SetAutoMuteAction final : public Action { public: explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f210..8105ac32eb1 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -7,7 +7,7 @@ namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 910ac902390..c41dde15c9d 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -12,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase { public: AirthingsWaveMini(); diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index 6f51f3c65ac..af355e45d63 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -19,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11 static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e50d8-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase { public: void setup() override; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 022d2650d2d..dcb5121c60f 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction : public Action { +template class ArmAwayAction final : public Action { public: explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -39,7 +39,7 @@ template class ArmAwayAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmHomeAction : public Action { +template class ArmHomeAction final : public Action { public: explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -51,7 +51,7 @@ template class ArmHomeAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmNightAction : public Action { +template class ArmNightAction final : public Action { public: explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -63,7 +63,7 @@ template class ArmNightAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class DisarmAction : public Action { +template class DisarmAction final : public Action { public: explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -75,7 +75,7 @@ template class DisarmAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class PendingAction : public Action { +template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -85,7 +85,7 @@ template class PendingAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class TriggeredAction : public Action { +template class TriggeredAction final : public Action { public: explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -95,7 +95,7 @@ template class TriggeredAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class AlarmControlPanelCondition : public Condition { +template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} bool check(const Ts &...x) override { diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index c63129031ad..5a5b01ac0b3 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -31,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12; static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; -class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 5a959af4c37..73dc0d87587 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -27,7 +27,7 @@ namespace esphome::am2315c { -class AM2315C : public PollingComponent, public i2c::I2CDevice { +class AM2315C final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index ddb5c6f1653..f92156b1542 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -6,7 +6,7 @@ namespace esphome::am2320 { -class AM2320Component : public PollingComponent, public i2c::I2CDevice { +class AM2320Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index aa48aced158..be7af59adeb 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { +class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 9198a5cbcbd..944681bb607 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index c768f1f82d6..a4df00ff05f 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -7,7 +7,7 @@ namespace esphome::analog_threshold { -class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { +class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index ca800ad9311..64cddbf09c9 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -5,7 +5,7 @@ namespace esphome::animation { -class Animation : public image::Image { +class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent); @@ -35,7 +35,7 @@ class Animation : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction : public Action { +template class AnimationNextFrameAction final : public Action { public: AnimationNextFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->next_frame(); } @@ -44,7 +44,7 @@ template class AnimationNextFrameAction : public Action { Animation *parent_; }; -template class AnimationPrevFrameAction : public Action { +template class AnimationPrevFrameAction final : public Action { public: AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->prev_frame(); } @@ -53,7 +53,7 @@ template class AnimationPrevFrameAction : public Action { Animation *parent_; }; -template class AnimationSetFrameAction : public Action { +template class AnimationSetFrameAction final : public Action { public: AnimationSetFrameAction(Animation *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, frame) diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index a3e175be280..49b1100c372 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; -class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { +class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 093ec55bc63..f971290cdd5 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -39,7 +39,7 @@ enum AmbientLightGain : uint8_t { }; static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18}; -class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 2823294207b..bfa64bcc745 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -12,7 +12,7 @@ namespace esphome::apds9960 { -class APDS9960 : public PollingComponent, public i2c::I2CDevice { +class APDS9960 final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(red) SUB_SENSOR(green) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc81150917..16b5762f683 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -342,7 +342,7 @@ class APIServer final : public Component, extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition : public Condition { +template class APIConnectedCondition final : public Condition { TEMPLATABLE_VALUE(bool, state_subscription_only) public: bool check(const Ts &...x) override { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index aef046fbb04..9e0faf98819 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -104,7 +104,7 @@ class ActionResponse { template using ActionResponseCallback = std::function; #endif -template class HomeAssistantServiceCallAction : public Action { +template class HomeAssistantServiceCallAction final : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { this->flags_.is_event = is_event; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 29eadda927a..ea57d0944bc 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -164,7 +164,8 @@ template class UserServiceTrig // Specialization for NONE - no extra trigger arguments template -class UserServiceTrigger : public UserServiceBase, public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} @@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ // Specialization for OPTIONAL - call_id and return_response trigger arguments template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} @@ -189,8 +190,8 @@ class UserServiceTrigger : public User // Specialization for ONLY - just call_id trigger argument template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} @@ -201,8 +202,8 @@ class UserServiceTrigger : public UserServ // Specialization for STATUS - just call_id trigger argument (reports success/error without data) template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} @@ -221,7 +222,7 @@ class UserServiceTrigger : public UserSe namespace esphome::api { -template class APIRespondAction : public Action { +template class APIRespondAction final : public Action { public: explicit APIRespondAction(APIServer *parent) : parent_(parent) {} @@ -286,7 +287,7 @@ template class APIRespondAction : public Action { // Action to unregister a service call after execution completes // Automatically appended to the end of action lists for non-none response modes -template class APIUnregisterServiceCallAction : public Action { +template class APIUnregisterServiceCallAction final : public Action { public: explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca8252..aa64fa5a4dc 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -6,7 +6,7 @@ namespace esphome::aqi { -class AQISensor : public sensor::Sensor, public Component { +class AQISensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; From 92028e53b5d55ee55608e361d6fa019ab5b8fe30 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:12 +1200 Subject: [PATCH 214/219] Mark configurable classes as final (2/21: as3935_i2c-ble_rssi) (#16953) --- esphome/components/as3935_i2c/as3935_i2c.h | 2 +- esphome/components/as3935_spi/as3935_spi.h | 6 ++--- esphome/components/as5600/as5600.h | 2 +- .../components/as5600/sensor/as5600_sensor.h | 2 +- esphome/components/as7341/as7341.h | 2 +- esphome/components/at581x/at581x.h | 2 +- esphome/components/at581x/automation.h | 4 ++-- esphome/components/at581x/switch/rf_switch.h | 2 +- .../atc_mithermometer/atc_mithermometer.h | 2 +- esphome/components/atm90e26/atm90e26.h | 6 ++--- esphome/components/atm90e32/atm90e32.h | 6 ++--- .../atm90e32/button/atm90e32_button.h | 12 +++++----- esphome/components/audio_adc/automation.h | 2 +- esphome/components/audio_dac/automation.h | 6 ++--- .../media_source/audio_file_media_source.h | 4 +++- .../audio_http/audio_http_media_source.h | 4 +++- .../touchscreen/axs15231_touchscreen.h | 2 +- esphome/components/ballu/ballu.h | 2 +- .../components/bang_bang/bang_bang_climate.h | 2 +- esphome/components/bedjet/bedjet_hub.h | 2 +- .../bedjet/climate/bedjet_climate.h | 2 +- esphome/components/bedjet/fan/bedjet_fan.h | 2 +- .../components/bedjet/sensor/bedjet_sensor.h | 2 +- .../beken_spi_led_strip/led_strip.h | 2 +- esphome/components/bh1750/bh1750.h | 2 +- esphome/components/bh1900nux/bh1900nux.h | 2 +- esphome/components/binary/fan/binary_fan.h | 2 +- .../binary/light/binary_light_output.h | 2 +- esphome/components/binary_sensor/automation.h | 20 ++++++++--------- .../binary_sensor_map/binary_sensor_map.h | 2 +- esphome/components/bl0906/bl0906.h | 4 ++-- esphome/components/bl0939/bl0939.h | 2 +- esphome/components/bl0940/bl0940.h | 2 +- .../bl0940/button/calibration_reset_button.h | 2 +- .../bl0940/number/calibration_number.h | 2 +- esphome/components/bl0942/bl0942.h | 2 +- esphome/components/ble_client/automation.h | 22 +++++++++---------- esphome/components/ble_client/ble_client.h | 2 +- .../ble_client/output/ble_binary_output.h | 2 +- .../components/ble_client/sensor/automation.h | 2 +- .../ble_client/sensor/ble_rssi_sensor.h | 2 +- .../components/ble_client/switch/ble_switch.h | 2 +- .../ble_client/text_sensor/automation.h | 2 +- esphome/components/ble_nus/ble_nus.h | 2 +- .../ble_presence/ble_presence_device.h | 6 ++--- esphome/components/ble_rssi/ble_rssi_sensor.h | 2 +- 46 files changed, 86 insertions(+), 82 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index c43ec4afd5b..c15f2d6e3e4 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -5,7 +5,7 @@ namespace esphome::as3935_i2c { -class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { +class I2CAS3935Component final : public as3935::AS3935Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index 935707a18c0..053e34b3d0e 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -8,9 +8,9 @@ namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; -class SPIAS3935Component : public as3935::AS3935Component, - public spi::SPIDevice { +class SPIAS3935Component final : public as3935::AS3935Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 414633f978b..a385322b70a 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -43,7 +43,7 @@ enum AS5600MagnetStatus : uint8_t { MAGNET_WEAK = 6, // 0b110 / magnet too weak }; -class AS5600Component : public Component, public i2c::I2CDevice { +class AS5600Component final : public Component, public i2c::I2CDevice { public: /// Set up the internal sensor array. void setup() override; diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 0086fe54ccd..170ff6d86b8 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -9,7 +9,7 @@ namespace esphome::as5600 { -class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { +class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { public: void update() override; void dump_config() override; diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 8bc157fe79a..2d72987f1cd 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -73,7 +73,7 @@ enum AS7341Gain { AS7341_GAIN_512X, }; -class AS7341Component : public PollingComponent, public i2c::I2CDevice { +class AS7341Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index e7f8ee36923..594395e96d8 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -12,7 +12,7 @@ namespace esphome::at581x { -class AT581XComponent : public Component, public i2c::I2CDevice { +class AT581XComponent final : public Component, public i2c::I2CDevice { public: #ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index eb8b1b25628..a732d2bcc79 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -7,12 +7,12 @@ namespace esphome::at581x { -template class AT581XResetAction : public Action, public Parented { +template class AT581XResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } }; -template class AT581XSettingsAction : public Action, public Parented { +template class AT581XSettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) TEMPLATABLE_VALUE(int, frequency) diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 47367fad45f..0e251b8baaf 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -5,7 +5,7 @@ namespace esphome::at581x { -class RFSwitch : public switch_::Switch, public Parented { +class RFSwitch final : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc13..3dde5f18680 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 657f8f3c433..0381d8e5c16 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -6,9 +6,9 @@ namespace esphome::atm90e26 { -class ATM90E26Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E26Component final : public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 5fa224b3535..c636e5065a5 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,9 +13,9 @@ namespace esphome::atm90e32 { -class ATM90E32Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E32Component final : public PollingComponent, + public spi::SPIDevice { public: static const uint8_t PHASEA = 0; static const uint8_t PHASEB = 1; diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 0cfce622934..988c6d5c167 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -6,7 +6,7 @@ namespace esphome::atm90e32 { -class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32GainCalibrationButton final : public button::Button, public Parented { public: ATM90E32GainCalibrationButton() = default; @@ -14,7 +14,7 @@ class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearGainCalibrationButton() = default; @@ -22,7 +22,7 @@ class ATM90E32ClearGainCalibrationButton : public button::Button, public Parente void press_action() override; }; -class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32OffsetCalibrationButton() = default; @@ -30,7 +30,7 @@ class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearOffsetCalibrationButton() = default; @@ -38,7 +38,7 @@ class ATM90E32ClearOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32PowerOffsetCalibrationButton() = default; @@ -46,7 +46,7 @@ class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearPowerOffsetCalibrationButton() = default; diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index e74e0232036..fc7af256228 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_adc { -template class SetMicGainAction : public Action { +template class SetMicGainAction final : public Action { public: explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 67bbc78ac21..9c5348271c2 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_dac { -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -16,7 +16,7 @@ template class MuteOffAction : public Action { AudioDac *audio_dac_; }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -26,7 +26,7 @@ template class MuteOnAction : public Action { AudioDac *audio_dac_; }; -template class SetVolumeAction : public Action { +template class SetVolumeAction final : public Action { public: explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 2c6189f2727..d269f77c357 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_file { // (the orchestrator calls set_listener() on us with a MediaSourceListener*). // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). -class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioFileMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6f..f794aa1f027 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_http { // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). // The two set_listener() methods live on different base classes and serve opposite directions. -class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioHTTPMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index 94d232777c6..43bd3799256 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -7,7 +7,7 @@ namespace esphome::axs15231 { -class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class AXS15231Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 8a45d39c703..cb40f415ad2 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -10,7 +10,7 @@ namespace esphome::ballu { const float YKR_K_002E_TEMP_MIN = 16.0; const float YKR_K_002E_TEMP_MAX = 32.0; -class BalluClimate : public climate_ir::ClimateIR { +class BalluClimate final : public climate_ir::ClimateIR { public: BalluClimate() : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index 1e5ff84883f..d83257f9f34 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -16,7 +16,7 @@ struct BangBangClimateTargetTempConfig { float default_temperature_high{NAN}; }; -class BangBangClimate : public climate::Climate, public Component { +class BangBangClimate final : public climate::Climate, public Component { public: BangBangClimate(); void setup() override; diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 9f25f7a4660..32ddd94cff7 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -33,7 +33,7 @@ static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("000 /** * Hub component connecting to the BedJet device over Bluetooth. */ -class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { +class BedJetHub final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index f59e67eeb7b..6f81b872898 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { +class BedJetClimate final : public climate::Climate, public BedJetClient, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 03f42f1438a..814a87d8b9c 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { +class BedJetFan final : public fan::Fan, public BedJetClient, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 0c3f713579d..c387e9d5fd3 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -7,7 +7,7 @@ namespace esphome::bedjet { -class BedjetSensor : public BedJetClient, public Component { +class BedjetSensor final : public BedJetClient, public Component { public: void dump_config() override; diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc4..909634e266e 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -19,7 +19,7 @@ enum RGBOrder : uint8_t { ORDER_BRG, }; -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/bh1750/bh1750.h b/esphome/components/bh1750/bh1750.h index 39dbd1d6a99..092a21359bc 100644 --- a/esphome/components/bh1750/bh1750.h +++ b/esphome/components/bh1750/bh1750.h @@ -13,7 +13,7 @@ enum BH1750Mode : uint8_t { }; /// This class implements support for the i2c-based BH1750 ambient light sensor. -class BH1750Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1750Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index 61d1bac268e..f1d62d16472 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -6,7 +6,7 @@ namespace esphome::bh1900nux { -class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1900NUXSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 17157dd29ca..601f4cb641a 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryFan : public Component, public fan::Fan { +class BinaryFan final : public Component, public fan::Fan { public: void setup() override; void dump_config() override; diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index f6be7e162e9..32707e8b0c8 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryLightOutput : public light::LightOutput { +class BinaryLightOutput final : public light::LightOutput { public: void set_output(output::BinaryOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1875910affd..d5a85ca9c42 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,7 +18,7 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger : public Trigger<> { +class PressTrigger final : public Trigger<> { public: explicit PressTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -28,7 +28,7 @@ class PressTrigger : public Trigger<> { } }; -class ReleaseTrigger : public Trigger<> { +class ReleaseTrigger final : public Trigger<> { public: explicit ReleaseTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -40,7 +40,7 @@ class ReleaseTrigger : public Trigger<> { bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); -class ClickTrigger : public Trigger<> { +class ClickTrigger final : public Trigger<> { public: explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -61,7 +61,7 @@ class ClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class DoubleClickTrigger : public Trigger<> { +class DoubleClickTrigger final : public Trigger<> { public: explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -127,7 +127,7 @@ class MultiClickTriggerBase : public Trigger<>, public Component { /// Template wrapper that provides inline std::array storage for timing events. /// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger : public MultiClickTriggerBase { +template class MultiClickTrigger final : public MultiClickTriggerBase { public: MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) : MultiClickTriggerBase(parent) { @@ -140,14 +140,14 @@ template class MultiClickTrigger : public MultiClickTriggerBase { std::array timing_storage_{}; }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class StateChangeTrigger : public Trigger, optional > { +class StateChangeTrigger final : public Trigger, optional > { public: explicit StateChangeTrigger(BinarySensor *parent) { parent->add_full_state_callback( @@ -155,7 +155,7 @@ class StateChangeTrigger : public Trigger, optional > { } }; -template class BinarySensorCondition : public Condition { +template class BinarySensorCondition final : public Condition { public: BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -165,7 +165,7 @@ template class BinarySensorCondition : public Condition { bool state_; }; -template class BinarySensorPublishAction : public Action { +template class BinarySensorPublishAction final : public Action { public: explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(bool, state) @@ -179,7 +179,7 @@ template class BinarySensorPublishAction : public Action BinarySensor *sensor_; }; -template class BinarySensorInvalidateAction : public Action { +template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index 60224242db6..bb2c2739574 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -29,7 +29,7 @@ struct BinarySensorMapChannel { * * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ -class BinarySensorMap : public sensor::Sensor, public Component { +class BinarySensorMap final : public sensor::Sensor, public Component { public: void dump_config() override; diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 821aac476c4..54de9f9b0cc 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -53,7 +53,7 @@ class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); -class BL0906 : public PollingComponent, public uart::UARTDevice { +class BL0906 final : public PollingComponent, public uart::UARTDevice { SUB_SENSOR(voltage) SUB_SENSOR(current_1) SUB_SENSOR(current_2) @@ -103,7 +103,7 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction : public Action, public Parented { +template class ResetEnergyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index b4f6d42e71f..333bca37152 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -56,7 +56,7 @@ union DataPacket { // NOLINT(altera-struct-pack-align) }; } __attribute__((packed)); -class BL0939 : public PollingComponent, public uart::UARTDevice { +class BL0939 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 14cb69d0b09..007fa990d52 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -33,7 +33,7 @@ struct DataPacket { uint8_t checksum; // Packet checksum } __attribute__((packed)); -class BL0940 : public PollingComponent, public uart::UARTDevice { +class BL0940 final : public PollingComponent, public uart::UARTDevice { public: // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index d528992d586..f5a4f50886e 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class -class CalibrationResetButton : public button::Button, public Component, public Parented { +class CalibrationResetButton final : public button::Button, public Component, public Parented { public: void dump_config() override; diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 062890d918e..186a34c5830 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -class CalibrationNumber : public number::Number, public Component { +class CalibrationNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index c3668786377..f926dd022d1 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -83,7 +83,7 @@ enum LineFrequency : uint8_t { LINE_FREQUENCY_60HZ = 60, }; -class BL0942 : public PollingComponent, public uart::UARTDevice { +class BL0942 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 01590d1d538..94eeb83b3eb 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -23,7 +23,7 @@ class Automation { }; // implement on_connect automation. -class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -37,7 +37,7 @@ class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { }; // on_disconnect automation -class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -61,7 +61,7 @@ class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +71,7 @@ class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyNotificationTrigger : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger : public Trigger, public BLE } }; -class BLEClientNumericComparisonRequestTrigger : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -94,7 +94,7 @@ class BLEClientNumericComparisonRequestTrigger : public Trigger, publi }; // implement the ble_client.ble_write action. -template class BLEClientWriteAction : public Action, public BLEClientNode { +template class BLEClientWriteAction final : public Action, public BLEClientNode { public: BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -231,7 +231,7 @@ template class BLEClientWriteAction : public Action, publ esp_gatt_write_type_t write_type_{}; }; -template class BLEClientPasskeyReplyAction : public Action { +template class BLEClientPasskeyReplyAction final : public Action { public: BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -268,7 +268,7 @@ template class BLEClientPasskeyReplyAction : public Action class BLEClientNumericComparisonReplyAction : public Action { +template class BLEClientNumericComparisonReplyAction final : public Action { public: BLEClientNumericComparisonReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -301,7 +301,7 @@ template class BLEClientNumericComparisonReplyAction : public Ac } value_{.simple = false}; }; -template class BLEClientRemoveBondAction : public Action { +template class BLEClientRemoveBondAction final : public Action { public: BLEClientRemoveBondAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -315,7 +315,7 @@ template class BLEClientRemoveBondAction : public Action BLEClient *parent_{nullptr}; }; -template class BLEClientConnectAction : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -364,7 +364,7 @@ template class BLEClientConnectAction : public Action, pu std::tuple var_{}; }; -template class BLEClientDisconnectAction : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef7..f27bef332b6 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -44,7 +44,7 @@ class BLEClientNode { uint64_t address_; }; -class BLEClient : public BLEClientBase { +class BLEClient final : public BLEClientBase { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 299de9b8605..8ea700529b8 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -11,7 +11,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, public Component { +class BLEBinaryOutput final : public output::BinaryOutput, public BLEClientNode, public Component { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 84430cb7d97..e805ebdb59c 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLESensorNotifyTrigger : public Trigger, public BLESensor { +class BLESensorNotifyTrigger final : public Trigger, public BLESensor { public: explicit BLESensorNotifyTrigger(BLESensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 570a5b423c9..e1590dbdebb 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { +class BLEClientRSSISensor final : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; void update() override; diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9be6d06b1c6..42b450243a5 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientSwitch : public switch_::Switch, public Component, public BLEClientNode { +class BLEClientSwitch final : public switch_::Switch, public Component, public BLEClientNode { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index d4114cd1bae..8a81610668c 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { +class BLETextSensorNotifyTrigger final : public Trigger, public BLETextSensor { public: explicit BLETextSensorNotifyTrigger(BLETextSensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index f1afd54af9e..82e2db69002 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -11,7 +11,7 @@ namespace esphome::ble_nus { -class BLENUS : public uart::UARTComponent, public Component { +class BLENUS final : public uart::UARTComponent, public Component { enum TxStatus { TX_DISABLED, TX_ENABLED, diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e80799485..e17e26ff1c4 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -8,9 +8,9 @@ namespace esphome::ble_presence { -class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d27..8e804ab8e70 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -8,7 +8,7 @@ namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; From 69f905f15448270b842803aaeba562c9e359e79a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 04:18:12 -0500 Subject: [PATCH 215/219] [ci] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1" (#17028) --- .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 6ff846e4b2d..aca6d9007a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 1753ccd81198b8a1cdf40374a12c478265c9e5c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:57:59 -0400 Subject: [PATCH 216/219] [ci] Update component-test CI for ESP-IDF default toolchain (#16383) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/actions/cache-esp-idf/action.yml | 14 +- .github/workflows/ci.yml | 89 +++-------- script/determine-jobs.py | 124 +++++++++------ tests/script/test_determine_jobs.py | 187 ++++++++++++++++------- 4 files changed, 247 insertions(+), 167 deletions(-) diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 7a17c222a39..f566ba4c434 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -2,8 +2,8 @@ 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 + natively (clang-tidy for IDF/Arduino and the component test batches) 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. @@ -11,6 +11,12 @@ inputs: framework: description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' default: espidf + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce an ESP-IDF install (e.g. a component batch with + no esp32 target), so a partial/empty install is never written to the key. + default: "false" runs: using: composite steps: @@ -33,13 +39,13 @@ runs: # 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' + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' 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' + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a8..29d42330cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,8 +270,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} - native-idf: ${{ steps.determine.outputs.native-idf }} - native-idf-components: ${{ steps.determine.outputs.native-idf-components }} + esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} + esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -324,8 +324,8 @@ jobs: 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 - echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT - echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT + echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT + echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -522,7 +522,6 @@ jobs: 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: @@ -592,7 +591,6 @@ jobs: 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 @@ -673,7 +671,6 @@ jobs: 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 @@ -758,7 +755,6 @@ jobs: 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 @@ -805,6 +801,10 @@ jobs: - common - determine-jobs if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 + env: + # esp32 component builds use the native ESP-IDF toolchain (default), so + # share the tidy jobs' install location -- the restore below lands here. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -832,6 +832,12 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install (restore-only) + # A batch may contain no esp32 build, so never save -- just reuse the + # shared install the dev tidy jobs already cached when present. + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -935,20 +941,19 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + test-esp32-platformio: + name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - # Comma-joined subset of the native-IDF representative component list, - # computed by script/determine-jobs.py (native_idf_components_to_test). + # Comma-joined subset of the esp32 PlatformIO representative component list, + # computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Single source of truth -- the full list lives in - # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. - TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} + # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -959,66 +964,22 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - 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: | - 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" - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - 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 - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build - else - 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 + - name: Run PlatformIO compile test run: | . venv/bin/activate echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation - echo "Disk space before config validation:" - df -h - echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio echo "" echo "Config validation passed! Starting compilation..." echo "" - # Show disk space before compilation - echo "Disk space before compilation:" - df -h - echo "" - # Run compilation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf - - - name: Save ESPHome cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio pre-commit-ci-lite: name: pre-commit.ci lite @@ -1353,7 +1314,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split - - test-native-idf + - test-esp32-platformio - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 4904883ca94..af3e83f96b3 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -466,11 +466,11 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False -# Components tested by the native ESP-IDF compile-test job. This is the +# Components tested by the PlatformIO compile-test job. This is the # single source of truth: the workflow reads the comma-joined list from the -# `native-idf-components` output of `determine-jobs` and uses it as the -# `TEST_COMPONENTS` env on the `test-native-idf` job. -NATIVE_IDF_TEST_COMPONENTS = frozenset( +# `esp32-platformio-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-esp32-platformio` job. +ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( { "esp32", "api", @@ -490,53 +490,75 @@ NATIVE_IDF_TEST_COMPONENTS = frozenset( } ) -# Path prefixes whose changes always trigger the native ESP-IDF compile -# test: anything under esphome/espidf/ (the native IDF runner / API / -# framework / component generator). -NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +# Path prefixes whose changes always trigger the PlatformIO compile test: +# anything under esphome/platformio/ (the PlatformIO runner / toolchain that +# drives every PlatformIO build). The esp32 platform component is already in +# ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal +# component-narrowing path. +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) -# Standalone files that, when changed, also trigger the native ESP-IDF -# compile test: -# - esphome/build_gen/espidf.py -- the native IDF build generator -# (other files under build_gen/ target PlatformIO and don't affect -# the native IDF path) +# Standalone files that, when changed, trigger the PlatformIO compile test: +# - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -NATIVE_IDF_TRIGGER_FILES = frozenset( +ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( { - "esphome/build_gen/espidf.py", + "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) -def _native_idf_path_or_file_trigger(files: list[str]) -> bool: - """Whether any changed file is a native IDF infrastructure / harness trigger.""" +def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" for file in files: - if file in NATIVE_IDF_TRIGGER_FILES: + if file in ESP32_PLATFORMIO_TRIGGER_FILES: return True - if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + if any( + file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ): return True return False -def native_idf_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. +# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator +# affect every esp32 IDF build (now the default toolchain) but aren't +# components, so the component matrix wouldn't otherwise force any esp32 +# compile. When they change we fold the `esp32` component into the matrix so +# the default native-IDF build path is still compiled on an infra-only PR. +ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"}) - The job builds components with the native ESP-IDF toolchain (no - PlatformIO). When only a specific component (or something it depends - on) changed, there's no value in re-building every other unrelated - component in the test list -- the regular ``component-test`` matrix - already covers them via PlatformIO. So we narrow to the intersection - of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + +def _esp_idf_infra_changed(files: list[str]) -> bool: + """Whether any changed file is ESP-IDF build/runner infrastructure.""" + for file in files: + if file in ESP_IDF_INFRA_TRIGGER_FILES: + return True + if any( + file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ): + return True + return False + + +def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP32_PLATFORMIO_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the PlatformIO toolchain. When only a + specific component (or something it depends on) changed, there's no + value in re-building every other unrelated component in the test list -- + the regular ``component-test`` matrix already covers them via the + default toolchain. So we narrow to the intersection of + ``ESP32_PLATFORMIO_TEST_COMPONENTS`` and the changed-component dependency closure. Returns the full list (sorted) when we can't safely narrow: 1. Core C++/Python files changed (``esphome/core/*``). - 2. Native IDF infrastructure changed (``esphome/espidf/*`` or - ``esphome/build_gen/espidf.py``). + 2. PlatformIO infrastructure changed (``esphome/platformio/*`` or + ``esphome/build_gen/platformio.py``). 3. The test harness or workflow itself changed (``script/test_build_components.py``, ``.github/workflows/ci.yml``). @@ -558,31 +580,31 @@ def native_idf_components_to_test(branch: str | None = None) -> list[str]: """ files = changed_files(branch) - if core_changed(files) or _native_idf_path_or_file_trigger(files): - return sorted(NATIVE_IDF_TEST_COMPONENTS) + if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) component_files = [f for f in files if filter_component_and_test_files(f)] changed = get_components_with_dependencies(component_files, True) - return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) -def should_run_native_idf(branch: str | None = None) -> bool: - """Determine if the `test-native-idf` compile-test job should run. +def should_run_esp32_platformio(branch: str | None = None) -> bool: + """Determine if the `test-esp32-platformio` compile-test job should run. - Runs whenever ``native_idf_components_to_test()`` returns a non-empty + Runs whenever ``esp32_platformio_components_to_test()`` returns a non-empty list. Skipping the job on unrelated Python-only PRs avoids ~5 min of CI per PR (worse on cold caches). The regular ``component-test`` - matrix still exercises the same components through PlatformIO when - those components change. + matrix still exercises the same components through the default + toolchain when those components change. Args: branch: Branch to compare against. If None, uses default. Returns: - True if the native ESP-IDF compile test should run, False otherwise. + True if the PlatformIO compile test should run, False otherwise. """ - return bool(native_idf_components_to_test(branch)) + return bool(esp32_platformio_components_to_test(branch)) def determine_cpp_unit_tests( @@ -1162,8 +1184,8 @@ def main() -> None: run_python_linters = True run_import_time = True run_device_builder = True - native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) - run_native_idf = True + esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + run_esp32_platformio = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1173,8 +1195,8 @@ def main() -> None: 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) + esp32_platformio_components = esp32_platformio_components_to_test(args.branch) + run_esp32_platformio = bool(esp32_platformio_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1228,6 +1250,18 @@ def main() -> None: if _component_has_tests(component) ] + # ESP-IDF build-gen/runner changed but no component pulled esp32 in: fold the + # `esp32` component into the matrix so the default native-IDF build path is + # still compiled on an infra-only PR. force_all/core already test everything, + # so skip there. Runs grouped (not added to directly-changed). + if ( + not is_core_change + and _esp_idf_infra_changed(changed) + and "esp32" not in changed_components_with_tests + and _component_has_tests("esp32") + ): + changed_components_with_tests.append("esp32") + # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation # (pin conflicts, etc.) since they contain the actual changes being reviewed @@ -1345,8 +1379,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, - "native_idf": run_native_idf, - "native_idf_components": ",".join(native_idf_components), + "esp32_platformio": run_esp32_platformio, + "esp32_platformio_components": ",".join(esp32_platformio_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index f8f359ee22b..a9876632bd9 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -68,13 +68,13 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: @pytest.fixture -def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: - """Mock native_idf_components_to_test from determine_jobs. +def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: + """Mock esp32_platformio_components_to_test from determine_jobs. - main() drives both the ``native_idf`` boolean output and the - ``native_idf_components`` CSV from this one function. + main() drives both the ``esp32_platformio`` boolean output and the + ``esp32_platformio_components`` CSV from this one function. """ - with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + with patch.object(determine_jobs, "esp32_platformio_components_to_test") as mock: yield mock @@ -115,7 +115,7 @@ def test_main_all_tests_should_run( 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_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -131,7 +131,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["api", "esp32"] + mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -213,8 +213,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "api,esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "api,esp32" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -248,7 +248,7 @@ def test_main_no_tests_should_run( 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_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -264,7 +264,7 @@ def test_main_no_tests_should_run( 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_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -305,8 +305,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False - assert output["native_idf"] is False - assert output["native_idf_components"] == "" + assert output["esp32_platformio"] is False + assert output["esp32_platformio_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -322,6 +322,65 @@ def test_main_no_tests_should_run( assert output["component_test_batches"] == [] +def test_main_esp_idf_infra_change_folds_esp32( + 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_esp32_platformio_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ESP-IDF infra-only change folds the `esp32` component into the matrix, + so the default native-IDF build path is still compiled.""" + 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_esp32_platformio_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + + # IDF build generator changed; no component changed. + mock_changed_files.return_value = ["esphome/build_gen/espidf.py"] + + 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=[] + ), + # esp32 has tests on disk, but pin it so the fold-in isn't coupled to layout. + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only `esp32` is folded in (not the whole representative set), and it's + # grouped, not isolated (infra changed, not the component). + assert output["changed_components_with_tests"] == ["esp32"] + assert output["directly_changed_components_with_tests"] == [] + assert output["component_test_count"] == 1 + + def test_main_with_branch_argument( mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, @@ -329,7 +388,7 @@ def test_main_with_branch_argument( 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_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -345,7 +404,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["esp32"] + mock_esp32_platformio_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -384,7 +443,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") - mock_native_idf_components_to_test.assert_called_once_with("main") + mock_esp32_platformio_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -398,8 +457,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "esp32" assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -916,23 +975,22 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() -_NATIVE_IDF_FULL_LIST_FILES = [ +_ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Core C++/Python changes -- caught by core_changed() ["esphome/core/component.cpp"], ["esphome/core/config.py"], - # Native IDF infrastructure paths - ["esphome/espidf/framework.py"], - ["esphome/espidf/component.py"], - ["esphome/espidf/api.py"], - ["esphome/build_gen/espidf.py"], + # PlatformIO subsystem (path-prefix trigger) + build generator + ["esphome/platformio/runner.py"], + ["esphome/platformio/toolchain.py"], + ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], ] -@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) -def test_native_idf_components_to_test_returns_full_list_on_infrastructure( +@pytest.mark.parametrize("changed_files", _ESP32_PLATFORMIO_FULL_LIST_FILES) +def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure( changed_files: list[str], ) -> None: """Infrastructure / core / harness changes fall back to the full component list.""" @@ -944,8 +1002,8 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( determine_jobs, "get_components_with_dependencies", return_value=["wifi"] ), ): - result = determine_jobs.native_idf_components_to_test() - assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + result = determine_jobs.esp32_platformio_components_to_test() + assert result == sorted(determine_jobs.ESP32_PLATFORMIO_TEST_COMPONENTS) @pytest.mark.parametrize( @@ -965,7 +1023,7 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], ), # api in the test set -- narrow to [api] even though the closure - # has other (unrelated to native-IDF coverage) entries. + # has other (unrelated to PlatformIO coverage) entries. ( ["esphome/components/api/api_connection.cpp"], ["api", "logger"], @@ -979,15 +1037,15 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ), # Pure Python-only change outside trigger paths -> empty. (["esphome/yaml_util.py"], [], []), - # Non-IDF files in esphome/build_gen/ do NOT trigger the full - # list -- only esphome/build_gen/espidf.py is a trigger. - (["esphome/build_gen/platformio.py"], [], []), + # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the + # full list -- only esphome/build_gen/platformio.py is a trigger. + (["esphome/build_gen/espidf.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), ], ) -def test_native_idf_components_to_test_narrowing( +def test_esp32_platformio_components_to_test_narrowing( changed_files: list[str], dependency_closure: list[str], expected: list[str], @@ -1001,12 +1059,12 @@ def test_native_idf_components_to_test_narrowing( return_value=dependency_closure, ), ): - result = determine_jobs.native_idf_components_to_test() + result = determine_jobs.esp32_platformio_components_to_test() assert result == expected -def test_native_idf_components_to_test_with_branch() -> None: - """native_idf_components_to_test passes branch argument through. +def test_esp32_platformio_components_to_test_with_branch() -> None: + """esp32_platformio_components_to_test passes branch argument through. Regression test: an earlier version called ``get_changed_components()``, which silently ignored the branch argument because that helper re-runs @@ -1021,7 +1079,7 @@ def test_native_idf_components_to_test_with_branch() -> None: ), ): mock_changed.return_value = [] - determine_jobs.native_idf_components_to_test("release") + determine_jobs.esp32_platformio_components_to_test("release") mock_changed.assert_called_once_with("release") @@ -1033,25 +1091,46 @@ def test_native_idf_components_to_test_with_branch() -> None: (["esp32", "api"], True), ], ) -def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: - """should_run_native_idf is a thin wrapper around the component list.""" +def test_should_run_esp32_platformio( + components_to_test: list[str], expected: bool +) -> None: + """should_run_esp32_platformio is a thin wrapper around the component list.""" with patch.object( determine_jobs, - "native_idf_components_to_test", + "esp32_platformio_components_to_test", return_value=components_to_test, ): - assert determine_jobs.should_run_native_idf() is expected + assert determine_jobs.should_run_esp32_platformio() is expected -def test_should_run_native_idf_with_branch() -> None: - """Test should_run_native_idf passes branch argument through.""" +def test_should_run_esp32_platformio_with_branch() -> None: + """Test should_run_esp32_platformio passes branch argument through.""" with patch.object( - determine_jobs, "native_idf_components_to_test", return_value=[] + determine_jobs, "esp32_platformio_components_to_test", return_value=[] ) as mock_inner: - determine_jobs.should_run_native_idf("release") + determine_jobs.should_run_esp32_platformio("release") mock_inner.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected"), + [ + # ESP-IDF runner / framework / build generator -> trigger + (["esphome/espidf/runner.py"], True), + (["esphome/espidf/framework.py"], True), + (["esphome/build_gen/espidf.py"], True), + # PlatformIO build gen and esp32 component are NOT IDF-infra triggers + (["esphome/build_gen/platformio.py"], False), + (["esphome/components/esp32/__init__.py"], False), + (["README.md"], False), + ([], False), + ], +) +def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: + """ESP-IDF build/runner infra paths are detected; other paths are not.""" + assert determine_jobs._esp_idf_infra_changed(changed_files) is expected + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -2751,7 +2830,7 @@ def test_main_force_all_overrides_detection( 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_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2772,7 +2851,7 @@ def test_main_force_all_overrides_detection( 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_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2813,9 +2892,9 @@ def test_main_force_all_overrides_detection( 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["esp32_platformio"] is True + # esp32_platformio_components is a CSV of ESP32_PLATFORMIO_TEST_COMPONENTS + assert "esp32" in output["esp32_platformio_components"].split(",") assert output["cpp_unit_tests_run_all"] is True assert output["cpp_unit_tests_components"] == [] assert output["benchmarks"] is True @@ -2826,7 +2905,7 @@ def test_main_force_all_overrides_detection( 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_esp32_platformio_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 @@ -2840,7 +2919,7 @@ def test_main_force_all_off_uses_detection( 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_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2855,7 +2934,7 @@ def test_main_force_all_off_uses_detection( 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_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2886,7 +2965,7 @@ def test_main_force_all_off_uses_detection( 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["esp32_platformio"] is False assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() From bf12af46458c023a372d76f07800426550b702c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 18 Jun 2026 09:31:49 -0400 Subject: [PATCH 217/219] [wifi] Add runtime suppression of post-connect roaming scans (#17012) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 16 ++++++ esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 61 ++++++++++++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp32-idf.yaml | 6 ++- 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 080a7bb97ba..1cfd2b9821a 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -764,6 +764,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" @@ -794,6 +795,19 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def enable_runtime_roaming_suppression() -> None: + """Enable runtime suppression of post-connect roaming scans. + + Components that are disrupted by the radio briefly going off-channel during a + roaming scan (e.g., audio playback) should call this function during their code + generation. This enables the request_roaming_suppression() and + release_roaming_suppression() APIs, which pause periodic roaming scans while active. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_ROAMING_SUPPRESSION_KEY] = True + + def request_wifi_ip_state_listener() -> None: """Request an IP state listener slot.""" CORE.data[IP_STATE_LISTENERS_KEY] = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) + 1 @@ -827,6 +841,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac2436..ffc6ea8e144 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -822,7 +822,7 @@ void WiFiComponent::loop() { } // else: scan in progress, wait } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { this->check_roaming_(now); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a1..c774e3a68ef 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,8 @@ #endif #include "esphome/core/string_ref.h" +#include +#include #include #include #include @@ -604,6 +606,49 @@ class WiFiComponent final : public Component { bool release_high_performance(); #endif // USE_WIFI_RUNTIME_POWER_SAVE +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + /** Request that post-connect roaming scans be suppressed. + * + * Components that are disrupted by the radio briefly going off-channel during a + * scan (e.g., audio playback) can call this to pause periodic roaming scans while + * active. Multiple components can request suppression simultaneously; roaming + * resumes once every requester has called release_roaming_suppression(). + * + * A roaming scan already in progress is allowed to finish; this only prevents new + * roaming scans from starting. The roaming interval timer is not reset, so roaming + * resumes on the next loop once suppression is released (and the interval elapsed). + * + * Note: Only supported on ESP32. + * + * Thread-safe: may be called from any task. + */ + void request_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: saturate at max instead of wrapping, so an excess of requests can't roll the + // counter back to zero and unintentionally re-enable roaming. + while (current < std::numeric_limits::max() && + !this->roaming_suppression_count_.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) { + } + } + + /** Release a roaming suppression request. + * + * Must be paired with a prior request_roaming_suppression() call. When all requests + * are released (count reaches zero), post-connect roaming resumes. A release with no + * outstanding request is ignored rather than underflowing the counter. + * + * Thread-safe: may be called from any task. + */ + void release_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: decrement only if non-zero, so an unmatched release can't wrap the counter + // and permanently suppress roaming. + while (current > 0 && + !this->roaming_suppression_count_.compare_exchange_weak(current, current - 1, std::memory_order_relaxed)) { + } + } +#endif // USE_ESP32 && USE_WIFI_RUNTIME_ROAMING_SUPPRESSION + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -732,6 +777,15 @@ class WiFiComponent final : public Component { void process_roaming_scan_(); void clear_roaming_state_(); + /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). + bool roaming_suppressed_() const { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + return this->roaming_suppression_count_.load(std::memory_order_relaxed) != 0; +#else + return false; +#endif + } + /// Free scan results memory unless a component needs them void release_scan_results_(); @@ -845,6 +899,13 @@ class WiFiComponent final : public Component { // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; uint8_t roaming_attempts_{0}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // Count of active roaming-suppression requests. Incremented/decremented from any task + // (e.g. audio playback), read in loop(). Roaming scans are paused while non-zero. + // Relaxed ordering is sufficient: the count value is the only data shared across threads, + // so no happens-before relationship with other memory needs to be established. + std::atomic roaming_suppression_count_{0}; +#endif #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904d..17b5e648622 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -312,6 +312,7 @@ #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE +#define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index b2b2233ef32..d000c611709 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,15 +1,19 @@ psram: -# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +# Tests the high performance and roaming suppression request/release APIs; +# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines esphome: platformio_options: build_flags: - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + - "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION" on_boot: - then: - lambda: |- esphome::wifi::global_wifi_component->request_high_performance(); esphome::wifi::global_wifi_component->release_high_performance(); + esphome::wifi::global_wifi_component->request_roaming_suppression(); + esphome::wifi::global_wifi_component->release_roaming_suppression(); wifi: use_psram: true From c694e96f24a5e7b047b08191ae1729dba29ab8ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 14:07:03 -0500 Subject: [PATCH 218/219] [logger] Hold recursion guard while draining the task log buffer The synchronous logging path holds the main-task recursion guard around notify_listeners_, but the buffered drain in Logger::process_messages_ did not. When a message logged from a non-main thread was drained on the main loop and a listener (the API log forwarder, or a logger.on_message automation) logged again on the main task, that re-entrant log reused the shared tx_buffer_ and the API shared_write_buffer_ while they were still in use, corrupting the in-flight message; on ESP32 this surfaced as a StoreProhibited panic in the API send path. Hold a RecursionGuard on main_task_recursion_guard_ across the drain so re-entrant main-task logs are dropped there too, mirroring the synchronous path. Adds an integration test that drives the buffered drain with a re-entrant on_message log and verifies the buffered messages are delivered uncorrupted. --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101d..684da0202e4 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 00000000000..058adbff990 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 00000000000..152e2042146 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + console_lines: list[str] = [] + api_messages: list[str] = [] + test_complete_event = asyncio.Event() + + def line_callback(line: str) -> None: + console_lines.append(line) + if "RACE_TEST_COMPLETE" in line: + test_complete_event.set() + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + def on_log(msg) -> None: + api_messages.append(msg.message.decode("utf-8", errors="replace")) + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # RACE_TEST_COMPLETE is logged from the main task, so it arrives even if the + # buffered path is corrupted. The buffered messages are drained afterwards. + try: + await asyncio.wait_for(test_complete_event.wait(), timeout=30.0) + except TimeoutError: + pytest.fail( + "Test did not complete within timeout; device likely crashed or hung. " + f"Collected {len(console_lines)} console lines." + ) + + # Allow the buffered messages to drain after the thread joined. + await asyncio.sleep(0.5) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From 0b37b9a202b9bf8d11040a25176bdc3f310ec351 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 14:51:04 -0500 Subject: [PATCH 219/219] [logger] Wait deterministically for buffered drain in test Replace the fixed asyncio.sleep(0.5) with a wait on an asyncio.Event that fires once all thread messages have arrived over the API. Every buffered message is delivered whether it survives intact or gets clobbered, so counting THREADMSG occurrences is a deterministic drain-complete signal with no arbitrary sleep and no dependence on the fix being present. --- .../test_logger_buffered_recursion_guard.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py index 152e2042146..5bef915b284 100644 --- a/tests/integration/test_logger_buffered_recursion_guard.py +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -45,17 +45,11 @@ async def test_logger_buffered_recursion_guard( api_client_connected: APIClientConnectedFactory, ) -> None: """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" - console_lines: list[str] = [] api_messages: list[str] = [] - test_complete_event = asyncio.Event() - - def line_callback(line: str) -> None: - console_lines.append(line) - if "RACE_TEST_COMPLETE" in line: - test_complete_event.set() + all_drained = asyncio.Event() async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config), api_client_connected() as client, ): device_info = await client.device_info() @@ -65,8 +59,17 @@ async def test_logger_buffered_recursion_guard( # Subscribe over the API: this is the exact path that crashed in the field # (the API log callback runs during the buffered drain). The API message field # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. def on_log(msg) -> None: - api_messages.append(msg.message.decode("utf-8", errors="replace")) + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) @@ -75,19 +78,16 @@ async def test_logger_buffered_recursion_guard( assert buttons, "Could not find Start Race Test button" client.button_command(buttons[0].key) - # RACE_TEST_COMPLETE is logged from the main task, so it arrives even if the - # buffered path is corrupted. The buffered messages are drained afterwards. + # Wait until every buffered thread message has been delivered over the API. try: - await asyncio.wait_for(test_complete_event.wait(), timeout=30.0) + await asyncio.wait_for(all_drained.wait(), timeout=30.0) except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) pytest.fail( - "Test did not complete within timeout; device likely crashed or hung. " - f"Collected {len(console_lines)} console lines." + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." ) - # Allow the buffered messages to drain after the thread joined. - await asyncio.sleep(0.5) - intact: set[int] = set() contaminated: list[str] = [] for raw in api_messages: